Repository navigation
feat(html): an HTML Input block, and remote control of the page it renders - #879
Conversation
What the DevTools frontend can and cannot be made to showThe right-hand developer pane is reasonable as a default, but it is not what an operator The alternative entrypoints exist, but none of them helpsCEF serves six working entrypoints:
Why the panel is always there
e ? this.rootSplitWidget.showBoth() : this.rootSplitWidget.hideMain()It can hide the page, never the developer panel. There is no built-in screencast-only Seeding the split state does not work either. It persists under What an operator can do today is drag the splitter to the right; that position is What is actually configurableGrepping the bundles for query parameters that are read: The real route: our own CDP clientIf we want just the page, the answer is to stop using the DevTools frontend. The proxy There are two payoffs, and the second is the significant one:
One detail in passingThe entrypoint HTML sets |
There was a problem hiding this comment.
Verdict: Request changes — the filter and the auth interlock hold up, but one stated behaviour of the link lifetime is contradicted by the code, and half the integration suite never ran at this head. (Posted as a comment: GitHub refuses this verdict from the PR's own account.)
Requested changes
- Keep an active session alive, or stop claiming it is. Expiry is pushed out only when a link is resolved or a socket opens —
backend/src/api/devtools/mod.rs:294—link.expires = now + LINK_TTL;— andpump()never touches it. Any later sweep then cancels a socket that has been in continuous use for 30 min:backend/src/api/devtools/mod.rs:235—if l.expires > now {. Either refreshexpiresfrompumpon client traffic (throttled, e.g. once a minute), with a unit test besidean_expiring_link_ends_the_session_on_itthat keeps a busy session past the TTL; or say in the body anddocs/HTML_RENDER.mdthat an open session is hard-limited to 30 min. - Forward what you parsed, not what arrived.
backend/src/api/devtools/filter.rs:127—_ if REMOTE_CONTROL_METHODS.contains(&method) => Ok(Forward::AsIs),— sends the client's raw bytes, whichserde_jsonand Chromium parse independently. SPECULATIVE (not verified): a message with twomethodkeys is judged by one parser's pick and executed by the other's. Sendingmessage.to_string()removes the question whichever way Chromium resolves duplicates, at no cost. - Rerun the red job —
gh run rerun 36272417465 --failed. See Tests & CI.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| "every use starts that over, so a session in progress does not expire under the operator" | CONTRADICTED |
item 1 |
| Debug port stays closed without Strom auth | CONFIRMED |
backend/src/main.rs:410 — if config.cef_debug_port.is_some() && !auth::AuthConfig::is_configured_in_env() { |
| Remote control is off per block by default | CONFIRMED |
backend/src/blocks/builtin/html_input.rs:532 — default_value: Some(PropertyValue::Bool(false)), |
Network.setCookie etc. apply to a live cefsrc (CEF 144) |
EXTERNAL |
author's measurement; assumed |
The one red job is test_jitterbuffer_recovers_with_drop_on_latency |
CONTRADICTED |
at 4b51d59 it is liveaudiorouter_test::a_routing_change_moves_audio_between_output_channels_while_playing (not-negotiated) |
Diagnosis — Right layer: the filter is in the proxy. Mint routes sit in protected_api_router. Page.reload, Page.navigate and history are rebuilt with only their needed params; Page.navigate / Strom.setHome go through the same normalize_url as the block URL.
Radius — SHARED: router, the generic live-write path in properties.rs (gated on an id ending :cefsrc) and CEF start-up flags. With cef.debug_port unset, only the new block changes behaviour. Both defaults above meet #823's shared-instance constraint.
Overlaps — #823: this PR is its chosen direction (my re-read of today). #810 owns the real-cefsrc CI this PR's untested half needs. #789 and #852 also edit main.rs / config.rs: textual conflicts. No new StromEvent variant, so #789's exhaustive match is not hit.
Tests & CI — Check (Linux) red at 4b51d59. cargo test stopped at liveaudiorouter_test: html_remote_control_property_test (4) and the api::devtools unit tests passed, but pipeline_lifecycle_test and every binary after it never ran. That flake is what #881 addresses. macOS/Windows skipped; nothing platform-specific. API Contract Check green.
Also — mod.rs is 1989 lines; CLAUDE.md asks for a split over 1500.
Confidence: MED — filter, pump, link table, URL check and start-up gate read; the page and frontend skimmed.
srperens
left a comment
There was a problem hiding this comment.
This supersedes my review at 4b51d59. The verdict stands at Request changes. The two new commits, 2e2acde (popups) and 7b8132f (gstcefsrc patch), add a feature. Neither addresses requested change 1 or 2, and Check (Linux) is red again at the same flake. (Posted as a comment: GitHub refuses this verdict from the PR's own account.)
Requested changes
- Still open: keep an active session alive, or stop claiming it is. Nothing in the new
session.rstouchesexpires. It is still refreshed only on resolve or open, atbackend/src/api/devtools/mod.rs:276—link.expires = now + LINK_TTL;. The sweep still cancels an open socket once 30 min have passed:backend/src/api/devtools/mod.rs:232—if l.expires > now {. The PR body still says "every use starts that over, so a session in progress does not expire". The module doc now makes the same claim ("afterLINK_TTLof disuse"). Refresh from the pump, or reword both. - Still open: forward what you parsed.
backend/src/api/devtools/filter.rs:158—_ if REMOTE_CONTROL_METHODS.contains(&method) => Ok(Forward::AsIs),. - Rebase onto
main. The branch's merge-base is61072ce, which predates #881's liveaudiorouter fix (3292cee). The rerun I asked for could not help. - Say in the body that the popup-close patch reaches no shipped image yet.
docker/strom-full/Dockerfileinstalls the releasedgstcefsrc-${GSTCEFSRC_VERSION}tarball from thegstcefsrc-depsrelease, and7b8132f's message says that tarball is not rebuilt. Until a maintainer publishes a patched build under a new name and bumps the version,strom-fullkeeps the orphaned-page bug. The "Verified in strom-full" row was a local image build.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| "a session in progress does not expire" | CONTRADICTED |
See item 1 |
| Another source's page cannot be switched to or closed | CONFIRMED |
SwitchPage and ClosePage both gate on family.contains(&target) in backend/src/api/devtools/session.rs. ClosePage also refuses the root. contains walks the opener chain, bounded by the target count. devtools::pages::tests::a_popup_from_our_page_is_ours_and_another_source_is_not ... ok |
| The patch stops a popup's close from nulling the element's browser | EXTERNAL |
Upstream gstcefsrc at the pinned GSTCEFSRC_REF. The Docker image is built only by manual/tag workflows, so no PR job applies the patch. SPECULATIVE (not verified): the early return also fires when src->browser is still null. If the element's own browser can close before OnAfterCreated runs, state never reaches CEF_SRC_CLOSED |
Radius: SHARED, carried from my previous review. The popup watch adds a browser-endpoint socket per session. It lives inside the pump and ends with it.
Overlaps: #823, #810, #789 and #852 are as in my previous review.
Tests & CI: I checked run 36419917992 at 7b8132f. Check (Linux) failed in liveaudiorouter_test (a_routing_change_moves_audio_between_output_channels_while_playing, a_fader_alone_keeps_a_fixed_point_bus_summing_in_float, panicking at line 417). The 6 new devtools::pages and 11 devtools::filter unit tests and html_remote_control_property_test ran first and passed. pipeline_lifecycle_test never ran. The other checks are green. macOS and Windows were skipped.
Also: mod.rs shrank from 1989 to 1782 lines, which is still over CLAUDE.md's 1500. Most of the rest is tests.
Confidence: MED. I read the filter, session, pages and link table. I skimmed the page JS.
An HTML source renders server-side, so an operator cannot click in it: a page behind a login stays on the login screen for as long as the flow runs. Chromium can be driven remotely over the DevTools protocol, and CEF opens that port when asked, so the operator can log in to the very browser that is on air rather than transporting a session into it. The port is unauthenticated and Chromium binds it to loopback. Keep it there and reach it through Strom's own port and authentication, the way WHEP and WHIP are already fronted: /json/list to find the pages, /devtools/* for the DevTools application (CEF serves the whole thing itself, so nothing is fetched from the internet), and the per-page WebSocket for the protocol. Strom terminates that socket and opens its own, which is what keeps Chromium's Origin check out of the picture. `cef.debug_port` enables it and nothing else does. `persist-session-cookies` rides along, because without it a login lands in a cookie Chromium keeps in memory only and the next flow start is logged out again. One CEF process serves every cefsrc in the instance, so this is an instance-wide privilege: from any target the protocol reaches every cookie in the profile and navigates anywhere, including file://. That is fine for an operator running their own instance and wrong for one whose HTML sources belong to different customers, where the isolation has to come from separate Strom instances. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ance One CEF process serves every cefsrc in a Strom instance, so a session opened against one HTML source is not confined to it: it reaches every other source, every page they are logged in to, and the files on the host. Keeping customers apart is an orchestration choice - a Strom process per customer - and not something this API can do, so the only honest thing is to say so where an operator will actually read it. Three places: the target listing carries the warning as a field, so every client says the same thing rather than inventing its own wording; the startup log states it once when the port is enabled; and the HTML rendering guide documents the flow with the same caveat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HTML sources have been raw cefsrc/cefdemux elements in imported pipelines - that is how Open Live drives them today. It works, but it leaves the operator writing caps by hand, and it leaves Strom with no name for "this HTML source". The block takes the URL, the viewport and the framerate, and exposes the page's audio as a second pad when asked. cefsrc sizes its browser from the caps negotiated downstream, so the viewport belongs here rather than in whatever follows. cefdemux is built only when audio is wanted, since that is the only thing it is there for; audio-only still renders the page, because the audio arrives as metadata on the video buffers and something has to consume them. A missing cefsrc or cefdemux says the plugin is gstcefsrc and that it ships in strom-full, rather than leaving a bare element-creation error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The link handed to an operator carried the API token, the Chromium target id and the address and port the DevTools application should call back on. Anyone the link passes to gets all of it, and a link is meant to be pasted into a browser or read off a phone screen. Now it is one opaque key: minting needs Strom's authentication, the key is the credential for everything beneath /devtools/<key>, and serving the application below the key is what lets its own relative paths work without a cookie carrying anything. The key expires after half an hour of disuse, every use starts that over so a session in progress does not expire under the operator, and it can be revoked outright. The only address that still appears is the host the operator themselves reached us on, taken from their own request, because the application has to be told where to open its socket. This also drops the previous handler's trick of turning an API token into a session cookie to get the application's own requests past authentication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An operator works in the graph, not in a list of Chromium targets, so the link comes from the block: a QR code to point a phone at, a button that opens a tab, and double-click on the node for the same thing the other blocks already do that way. The block decides whether it can be controlled at all. Remote Control is off by default, and the server refuses to mint a link without it - that is a deliberate step rather than a security boundary, since a session still reaches every HTML source in the instance, and the panel says so next to the code. The server resolves the block to a browser, because only it knows which page a block is rendering, and the answer changes whenever the page is recreated. It matches on the URL, which is all Chromium exposes about a browser, so two blocks sharing a URL are indistinguishable and the request is refused with a reason rather than guessing between them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The property maps to `_block`, and block-level properties are otherwise refused at runtime as having no element to write to. That is right for the viewport, which is baked into the capsfilter when the block is built, and wrong for this one: nothing in the pipeline reads it, the API reads it from the stored block when a link is minted, so storing it is the whole write. It also has to work on a running flow to be worth having. An operator turns it on to intervene in a page that is already on air, which is exactly when stopping the flow is not an option. Same shape as the Live Audio Router's crosspoint fade, handled before the `_block` rejection. The guard test fails with the special case removed - the switch comes back rejected - and the companion test pins the viewport as not live, so the two cannot drift into each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correctness: - Resolve the block's URL through one shared fallback, so a block left at its default can mint a link instead of being told it has no URL set. - Match a block to its Chromium target in three tiers (exact URL, same origin, sole survivor) so a login redirect no longer makes the page unfindable. Ambiguity is refused rather than guessed at, and matching is instance-wide because one CEF process serves every source. - Gate the target-id endpoint on the block's Remote Control property, by the same rule the block endpoint uses, so the two cannot disagree. - Give both cefdemux branches a queue, as gstcefsrc's own pipeline does, so one branch cannot stall the other. - Follow the debug port actually in force when the operator has set remote-debugging-port in GST_CEF_CHROME_EXTRA_FLAGS, rather than dialling the configured one. - Reject a non-boolean Remote Control value instead of storing it and reporting it as applied. - Bound the viewport properties so a large value cannot wrap when cast. - Carry the flow and block ids as a pair rather than parsing them back out of a storage key. - Reuse one HTTP client for the proxied DevTools assets. - Revert an unnecessary visibility widening in auth. Security: - Revocation and expiry now end a session that is already open. The socket outlived both before, so the only emergency stop closed the door to new sessions while leaving the browser under the holder's control. - Redact any 64-hex path segment from the request span. The key is the credential and the DevTools application loads roughly a hundred files under it, so it was written to the log on every request. - Validate X-Forwarded-Host as a bare host and port, and percent-encode it, so it cannot inject query parameters into the redirect. - Give each link a non-secret id, list the live links without ever handing the key back, and add a revoke-all. Revoking is keyed on the id, so it no longer requires keeping a copy of the credential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A link now opens Strom's own page: the source as it is being rendered, with clicks and keystrokes passed back to it. The proxy forwards only the screencast and input methods and answers everything else with a protocol error, so the link is what it looks like. The allowlist is the control, and it cannot be added later. DevTools needs exactly the domains the filter exists to refuse - Runtime, Debugger, DOM, Network - so serving DevTools and filtering the protocol are two modes, not one with a switch. Shipping the unfiltered one first would mean never being able to restrict it without taking the feature away. The feature's own purpose, letting an operator log in to a page that is on air, needs a picture and an input device and nothing more. Without the filter a link was arbitrary file read on this host, every cookie in the shared profile, and a HTTP client inside the network - handed, by design, to whoever the operator wanted to have log in. Chromium's DevTools application stays available behind cef.full_devtools, off by default, documented as full control of the browser. The DevTools asset route is served only in that mode. Remote control also now requires authentication to be configured. auth_middleware passes everything when authentication is off, so minting was unauthenticated whenever it was; the debug port is no longer opened at all in that case. - Refuse anything outside SCREENCAST_METHODS, answering the command it refuses by id so a client is not left waiting. - Serve the remote control page from under the key. It derives the socket from its own location, so this path trusts no forwarded header. - Say which of the two modes a link is, in the warning the API hands clients and in the block's property description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The warning still described full DevTools - every page, every cookie, the host's files - when links carry only the screencast and input. Say what a link grants in the mode that is in force, and keep saying that the port itself is full control to anything that can reach loopback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ady uses Open ran the link through make_external_url, which swaps localhost for the server's hostname. That is for a QR code read by another device. For a tab opened in this browser it only breaks things wherever the server's name does not resolve here - behind Docker it became the container id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…control page An operator logging in needs to get back from a wrong click, and a phone is what is at hand. The page now has back, forward, reload and home, takes taps, drags and the on-screen keyboard, and says which flow and block it controls. Navigation stays inside what the browser has already shown. History steps and reload are forwarded with their parameters rebuilt: Page.reload also takes scriptToEvaluateOnLoad, which is arbitrary JavaScript. Home is Strom.goHome, which the proxy turns into Page.navigate to the block's own URL, so the client never names an address. Typing an address stays out: from inside the network, http alone reaches whatever the server can. The proxy opens each session with a Strom.context message carrying the flow and block names and the home URL, so the header can say what is under control without a new endpoint. Touch: one finger taps as a click and drags as wheel scrolling, and a hidden text field takes the phone's keyboard, with its text sent as Input.insertText. The mouse wheel was inverted - the protocol takes the DOM's sign, measured against CEF 144 - and Enter and Tab now carry their characters so a form submits. The filter moves to devtools/filter.rs, which keeps devtools.rs from growing past where it already was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stopped flow renders nothing, but its HTML blocks were counted as sources still looking for their page. The sole-survivor rule - one page left over, one source left over - then refused any page that had redirected across origins, which a bare google.com landing on www.google.com already does. Only running flows are considered now. A block whose flow is stopped now gets the "is the flow running?" answer instead of "Block not found". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt page An HTML source renders http, https and data URLs and nothing else. It is an allowlist, because a list of what to refuse misses view-source:file:// and every scheme Chromium keeps its own pages behind. A bare address is read as https://. The check holds in three places, all through normalize_url: when the block is built, when its URL changes on air, and in the proxy for Page.navigate. The remote control address bar now takes an address, and a pin button makes the page the operator is on the block's URL (Strom.setHome), so the source starts there from then on. The proxy performs that against Strom and broadcasts FlowUpdated so the UI follows. The link warning says what this now reaches: whatever the server reaches over http, and script of the holder's choosing through a data address. The URL was never actually live: cefsrc loads a new url into a running browser but does not flag the property GST_PARAM_MUTABLE_PLAYING, so the generic write path refused it. html_input::try_apply_live_url sets it directly, the way time_offset intercepts its own live write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A login that opens a window - "Sign in with...", an OAuth consent screen - did nothing an operator could see: the popup is a target of its own, and the session only ever showed the page the link was minted for. With the Chrome runtime a popup is not painted into cefsrc's video, so it never went on air, but it could not be reached either. The proxy now watches the browser's targets (Target.setDiscoverTargets on the browser endpoint) and follows the ones the session's page opened, through any number of popups. A new popup is shown straight away, since the operator just clicked for it, and when it closes - an OAuth window closes itself when it is done - the session goes back to whoever opened it. The page lists the windows as tabs, and a popup can be closed from there. Strom.switchPage and Strom.closePage name a target, and the proxy checks it is the session's page or one opened from it. Another HTML source's page, or its popups, cannot be switched to or closed. Home goes back to the session's own page before navigating. Popups were also counted as pages when matching a page to its block. An open popup looked like a page no block could claim, which could make a link refuse or pick wrongly. page_targets and the target listing now leave out anything with an opener; /json/list does not say, so both read the browser endpoint. pump moves to devtools/session.rs and the popup logic lives in devtools/pages.rs, which keeps devtools/mod.rs from growing. Verified against CEF 144 in strom-full: a click through the link opens the popup and the session switches to it and screencasts it at its own size; another source's page is refused for switching and closing; minting still works with the popup open; when the popup closes itself the session is back on the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oses A popup the page opens is created with the element's own CEF client, so its OnBeforeClose arrives in the same handler, which set src->browser to null and the state to closed without asking which browser it was. The video kept flowing, since painting does not look at that pointer, but stop() checks it: with it null, the page was never closed. It kept running - its JavaScript, its network traffic and whatever it was logged in to - until the process exited. Every page that opens and closes a popup did this, which is exactly what an OAuth login does. It also broke remote control afterwards: the orphaned page and the restarted one share a URL, so a link for the block was refused as ambiguous. The patch, applied when gstcefsrc is built, ignores a close for any browser but the element's own. Verified in strom-full with CEF 144: a page opens a popup that closes itself, then the flow stops. With the stock plugin the page is still listed by CEF afterwards; with the patch it is gone. The released gstcefsrc tarball is not rebuilt here: it is named by CEF version only, so a patched build needs a suffix before it can be published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…was parsed A link's expiry was pushed out only when the link was resolved or a socket opened, so a session the operator was working in was cut off 30 minutes after it started, while the docs said it would not be. The session now holds its link: input and navigation from the operator start the time over, and the session sweeps the table itself, so a tab left open and only watched is ended by the expiry even when nothing else asks about links. Allowed methods were forwarded as the client sent them. They are now rebuilt from the parsed id, method and params, so anything else on the message - a top-level sessionId, say - stays behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cefsrc in a Strom process shares one CEF request context: gstcefsrc creates each browser with a null context, which is the global one. Two flows' HTML sources therefore share cookies, local storage and the HTTP cache - on a Strom shared by several customers, a login made for one is seen by the others. Measured in the strom-full image: flow B read the session cookie, persistent cookie and localStorage flow A's page had set. gstcefsrc has no setting for this, pinned or upstream. The patch in docker/gstcefsrc adds three cefsrc properties, all off by default: - isolated-context: create the browser in its own CefRequestContext. Popups opened by the page inherit it. - context-cache-path: persist that context. Chromium accepts a profile only as a direct child of the root cache path, and silently keeps one in memory anywhere deeper. - persist-session-cookies: the Chromium flag reaches only the global context; an isolated one carries its own setting. A persisted context initializes asynchronously, and CreateBrowserSync in it returns null - gstcefsrc then crashed on the next line. The browser is now made in OnRequestContextInitialized, and a null browser is an element error instead of a crash. The HTML Input block turns isolation on when the plugin has it and warns when it does not. A new browser_profile property names a profile several sources share; empty gives the block its own, keyed by flow and block id, since block ids are only unique within a flow. Profiles live in the CEF cache directory, so they survive a flow restart and, in the Docker image, last until the container is replaced. Verified against CEF 144 in strom-full with the patched plugin: two flows isolated, one profile name shared across flows, a restart keeps session and persistent cookies and storage, a popup sees its opener's context. Not in this commit: publishing a patched gstcefsrc build (the release tarball is named by CEF version only, so it needs a suffix), removing a block's profile when the block is deleted, and DSK graphics, which use cefsrc outside this block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not be shared The browser context per source reached only the HTML Input block. A flow built from raw cefsrc elements - how Open Live drives HTML sources, and how DSK graphics are made - still put every page in the one global context. The isolation now lives in html_input::isolate_browser and is applied to every raw cefsrc as well, with a profile keyed by flow and element id under its own prefix, unless the flow sets isolated-context itself. A filtered remote control link is scoped to one source only when the plugin isolates. Without isolation a login made through it is a login for every HTML source in the instance, so the link's warning now says that, asked of the plugin when the link is minted. docs/HTML_RENDER.md gets a section on profiles and on sharing one Strom between customers: possible with Strom's gstcefsrc build, not without it; full DevTools stays off; profile names and who may mint a link for which source are the job of whatever drives Strom for several customers, or else one Strom container per customer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matching a Chromium page to the HTML source that renders it is one job with its own tests, and mod.rs was over the 1500 lines CLAUDE.md allows. It moves to devtools/placement.rs unchanged; mod.rs is down to 1442 lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7b8132f to
ac58994
Compare
- Check a live URL write in try_apply_live_url itself. The raw element endpoint and MCP reach it without update_block_properties, so file:// or a metadata address could be loaded on air. A cefsrc without the strict-network property is treated as strict. - Revoke links when their block stops allowing them. Switching Remote Control off now broadcasts FlowUpdated; a watcher revokes links whose flow or block is gone or whose switch is off, which also ends open sessions. The link handlers and the session's hold tick check too. - Count raw cefsrc elements as HTML sources when placing a page, so a block's link cannot open a page rendered by a raw element in another flow. They never get a link. An exact URL match is also refused when another source names the same URL. - Flush settling live property writes every frame, not only while a block is selected, so a URL typed just before deselecting is sent. - Default URL is https://google.com. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qAAXC2wcTykBKqGDEYzM7
window.print() froze the page: with no print handler, CEF went into GTK's print path inside Strom and never returned, tripping a GLib assertion on the way. offscreen-handlers.patch now cancels every print, so print() returns at once. Pages that will not start without a camera and a microphone - a video call joined to be watched - were refused outright. Strom now runs Chromium with use-fake-device-for-media-stream, playing a silent WAV and a black Y4M frame it writes into the CEF cache directory at startup. Chromium's own fake devices beep and show a green card, which the others on a call would hear and see. The patch grants camera and microphone only when that switch is on, so a page gets the synthetic devices and never the server's; screen capture is always refused. WebRTC UDP is allowed again for strict sources. Blocking it bought little: a page can only make the browser send STUN connectivity checks, not data of its own, while it broke most WebRTC pages, a real use as sources. The remaining exposure - probing internal UDP ports - is documented in HTML_RENDER.md, which also gets a section on devices, dialogs and console output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gstcefsrc-144.0.21-strom4 (build run 36874402689): offscreen-handlers now cancels printing and grants fake media devices, and strict-network no longer restricts WebRTC UDP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logging in to Microsoft Teams from its sign-in page runs the whole login in a popup. When it is done the popup is the window that is logged in and showing Teams, while the source's own page - the one on air - is still on the sign-in page. The profile is shared, so pasting the popup's address into the source worked, but it was an extra step every time. Each popup tab on the remote control page gets a button that loads the popup's page into the source and closes the popup. It sends Strom.adoptPage with the popup's target id only: the proxy looks the address up in the target info it already follows, refuses anything but this session's own popups, and puts the address through the same scheme and Strict Network Access checks as any other navigation. Setting it as the start page stays a separate step, so a one-time address with a login token in it is not saved by accident. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mod.rs was 1626 lines, over the 1500 CLAUDE.md sets; most of it was tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot break A profile was never removed with its block, so on a cache that outlives the process every HTML source that ever ran left a directory behind, holding the cookies of whatever it was logged in to. Deleting a flow now removes its sources' own profiles, and startup removes every block, element and named profile no stored flow uses. Startup is the only point where removing one is safe for a block taken out of a flow that keeps running. Chromium locks its cache root with a SingletonLock naming host and pid, and treats a lock from another host as held. After a hard kill under a different hostname - a laptop changing networks is enough - every cefsrc failed with a bare "Failed to start". The cache root is per data directory, so a lock from another host is stale; Strom clears it before CEF starts. A lock from this host is left to Chromium, which checks the pid itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its URL Remote control found a block's page by matching URLs, because a URL is all Chromium says about a page. That broke in both directions: a page that had logged in or redirected to another site was reported as "not rendering a page yet" while it was on air, and two blocks on the same URL - two HTML Input blocks left on the default address, say - were refused as ambiguous. Chromium's target id, unlike the URL, stays the same for the whole life of a page, across navigations to any site (measured against CEF 144). So with the debug port open, every cefsrc now starts on about:blank#strom-<token>, a token nothing else knows. Once Chromium lists a page on that URL, Strom records its target id against the block or element and points the element at its real URL. The blank start is Chromium's initial empty document, which the first navigation replaces, so it leaves nothing in the page's history. A URL set on air while the page is being born wins. This needs nothing from gstcefsrc beyond its url property, so it works with the upstream plugin as well as Strom's build. Without a debug port nothing changes: the element loads its URL straight away. The URL matching in placement.rs goes, and with it the 409 for two blocks on one URL. A link is minted for the page the block was born with, and the target endpoint traces a page back to the block it was born for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CEF build comes from Spotify's binaries, which carry no proprietary codecs, so a page has no H.264: Teams gives audio but no video, while YouTube and Meet work. GPU mode does not give a page hardware video on Linux either, since Chromium needs VA-API and NVIDIA has none in the images. The HTML Input block and remote control run on an upstream gstcefsrc, so list what each of Strom's four patches buys, the popup-close and offscreen-handler gaps first. Note audio and video inputs for the block as a feature idea. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one soon A URL written on air between the element starting and its page being named could be overwritten by the URL the element was built with: the birth task read the element's url and then set it, with the live write in between. Both now go through one lock, and a live write to a page still being born replaces the URL it will load instead of reaching the element. The wait for a page was 60 s from build, so a debug port that never answered left the source blank on air for a minute. The clock now starts when the element has started, which cefsrc does only once its browser exists, and Strom gives up after 5 s and loads the URL anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A link was bound to the page it was minted against, so after a flow restart it opened onto a page that no longer existed and the operator saw a dead screen with no word why. A link now names the block: a session opens on whatever page the block renders when it starts, and says so when there is none (Strom.unavailable). The listing names links by flow and block instead of the URL they were minted on. In a session: - the start page cannot be set while a popup is on show, which put the popup's one-time login address on air and saved it; - the source's page closing ends the session wherever it is looking, and the 15 s check now also ends it when the flow is no longer running; - the home address and Strict Network Access are read from the block when the session opens, on every check and on Home, instead of being frozen when the link was minted; - answers to Strom's own commands survive a switch of window, so loading a popup into the source is confirmed. The remote control page names the source and nothing else: no product name and no flow name, so it can be handed on or put behind another product. The UI shows the warning the server returns with a link instead of a fixed sentence that was wrong in filtered mode, and keeps the link in memory: it went through local storage, which is on disk in a browser. Live property writes were debounced per block id, which repeats across flows, so typing in two flows could drop one of the values. The key now includes the flow. Deleting a flow that would not stop no longer removes its profiles under a browser that may still be in them. Stopped flows' pages are forgotten. While a page is being born, reading the element's url gives the URL it is about to load, never the marker, so a client cannot save the marker back. CEF setup moves out of main.rs into cef_flags. On upstream gstcefsrc, which keeps only the last enable-features, Strom's network checks now come first with the navigation check last, and an enable-features the operator set comes after them all and is the one kept. The fake-device switch is passed even when Strom cannot write its silent devices, so a page gets Chromium's test tone rather than the server's real camera and microphone. Stale text: OpenAPI responses no longer say a link opens DevTools; the devtools module doc and the strict-network property no longer claim WebRTC is restricted or that there is one cookie jar; HTML_RENDER.md documents the block endpoint and the block's audio output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…safe to drive Strom's gstcefsrc patches fix what an offscreen browser does wrong, but a Strom on upstream gstcefsrc had none of that: a click on a file input through remote control built a GTK dialog in-process and could abort Strom, print() froze the page, a download landed on the server's disk, and once a popup closed the element ignored URL changes and left its page running after stop. Strom cannot tell from the plugin which patches it has. With the debug port open, every named page now gets a guard: a DevTools session of Strom's own for the page's life that intercepts file choosers, denies downloads, makes print() a no-op and dismisses JavaScript dialogs. These hold only while a session is attached, so it stays attached. The guard is in place before the page leaves its blank start. On an element without isolated-context, which every Strom build has and upstream lacks, an on-air URL goes to the page over DevTools instead of through the element, and property reads report it. Stopping a flow closes any of its pages Chromium still lists. The guard attaching to the blank start made Chromium keep it as a history entry, so Back put a blank page on air. The guard clears the history once the page has left the start and finished loading, retrying if Chromium is mid-swap. Measured in strom-full on both upstream gstcefsrc (latest) and Strom's build (dev-e19ffe7), with a page that prints, alerts, confirms and downloads on load, a file input clicked through remote control, and a popup opened and closed: Strom stays up, the page keeps rendering, nothing is written to disk, an on-air URL change after the popup lands on both, and on upstream the leftover page is closed on stop. History is clean on first start and after a restart on both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The debug port was opt-in through cef.debug_port, but nothing outside Strom connects to it: links go through Strom's proxy, and the page guards need it for every flow, remote control or not. With authentication configured Strom now opens it on a free loopback port, and cef.debug_port only pins the number. Whether a block hands out links is its Remote Control switch, as before. Without authentication the port stays shut, and the first HTML source built says once that its page is not guarded. Pages' console output also stays out of the log on upstream gstcefsrc and on native runs: Chromium logs every console message at INFO, and Strom now sets GST_CEF_LOG_SEVERITY to warning unless it is set, as the strom-full entrypoint does. Measured on upstream gstcefsrc with a page logging on all four console levels for three seconds: 1200 lines per level with the old default, none with this one. In the same run, with no cef.debug_port set, print() returned on the page, so the guard was on the port Strom picked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gstcefsrc runs Chromium with enable-media-stream, which grants media requests without asking the element, so the permission handler in Strom's patch never ran. Measured on upstream and on Strom's build alike: getDisplayMedia was granted and returned the display. In strom-full that is an empty Xvfb screen; on a desktop it is the operator's real one. The guard now replaces getDisplayMedia, and print, on the prototype before any page script runs, so the page cannot take them back. A frame from another site is a target of its own that the page's commands do not reach, so the guard auto-attaches to them, holds each until it has the same script, and lets it run. Measured on upstream gstcefsrc: getDisplayMedia is refused with NotAllowedError on the page and in a cross-site iframe allowed display-capture, print is the no-op in the frame, camera and microphone are still the fake devices, and the earlier guard checks still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…entication too Measured what each gstcefsrc patch still buys now that Strom guards every page over DevTools, by building gstcefsrc locally with fewer patches and running the same checks in strom-full against each build. offscreen-handlers goes. The guard does what it did: file choosers, downloads, print and JavaScript dialogs. Its media handling never ran, because gstcefsrc's enable-media-stream grants requests without asking the element: getDisplayMedia was granted on every build, which the guard now refuses. A right click through remote control does not take Strom down without it. Console output stays out of the log through CEF's log severity. popup-close stays. Without it, with isolated-context, the next flow start after a popup had closed aborted Strom with a corrupted heap. Closing the page the element had lost runs the element's close handler on freed memory, so Strom no longer closes such a page on stop; it says it is still running. Live URL changes go to the page over DevTools on every build, since the element ignores them once a popup has closed. strict-network stays. Without it a page's request to a local address hangs instead of being refused, a loose source cannot let a public page reach local addresses, and upstream keeps one enable-features, so WebSocket and WebTransport are not checked. The debug port now opens whether or not authentication is configured: authentication gates minting links, and does not change who can reach a loopback port. Every page is guarded either way. A deleted flow's profile that Chromium is still writing is retried in the background instead of being left with a warning. Measured on a local build with popup-close, isolated-context and strict-network: three rounds of the remote-control regression without a crash, isolation between two sources, screen capture refused in the page and in a cross-site frame, strict refusing loopback and loose reaching it, a loose source loading a local page, and the guard without authentication. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spotify's web player will not play in an HTML source: it needs Widevine,
and requestMediaKeySystemAccess('com.widevine.alpha') is refused. libcef
has Widevine support built in (it looks for a WidevineCdm directory), but
the CDM is downloaded at runtime by Chromium's component updater, and the
entrypoint passes disable-component-update and
disable-background-networking in every mode.
STROM_CEF_WIDEVINE=1 leaves those two flags out, so Chromium can fetch the
CDM. Opt-in until it is measured: on arm64 nothing arrived in 90 seconds,
as expected, since Google ships the Linux CDM for x86_64 only. Whether
Spotify then plays on amd64 is not known yet; this CEF build has no AAC
either, which depends on what Spotify serves Chromium.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gstcefsrc-144.0.21-strom5 (build run 37004818856) carries popup-close, isolated-context and strict-network. Checked before switching: the arm64 archive's sha256 matches the release digest, version.txt lists the three patches, and in strom-full with the published plugin the remote-control regression ran twice without a crash, two sources stayed isolated, screen capture was refused, and strict refused loopback while loose reached it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…play Spotify's web player needs Widevine and AAC. With STROM_CEF_WIDEVINE=1 the CDM is fetched, but the page reports audio/mp4 mp4a.40.2 as unsupported, the same proprietary-codec gap as H.264. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts e0050f0. The CDM did download, but Chromium on Linux registers a component-updated Widevine only at its next start, and the entrypoint clears the CEF cache on every start, so the CDM was never usable: requestMediaKeySystemAccess refused com.widevine.alpha even for codecs the page supports. Spotify would not play anyway, since the CEF build has no AAC. The docs no longer name the variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Code review: one issue I'd fix before merge (the shown_url comment on properties.rs), plus three smaller ones. Inline comments below.
| let v = obj.property::<Option<String>>(name); | ||
| v.map(PropertyValue::String) | ||
| // A cefsrc whose page is being born holds a marker, not its URL. | ||
| v.map(|url| crate::cef_pages::shown_url(obj, url)) |
There was a problem hiding this comment.
Every string property of a steered cefsrc reads back as the page URL.
shown_url is applied to every gchararray property, not just url. Once a page has loaded, Strom steers its cefsrc (every HTML Input block, now the debug port is always open), and from then on shown_url returns the steered URL whatever property it is asked about. name, context-cache-path and every other string property of that element therefore report the page URL. A client that reads properties and writes them back stores the URL into all of them.
It also takes the steered() mutex for every string property of every element in every pipeline.
Suggested fix: only call it when name == "url".
| strom_types::PropertyValue::String(url.clone()), | ||
| )]); | ||
| let result = app | ||
| .update_block_properties(&source.flow_id, &source.block_id, properties, None, None) |
There was a problem hiding this comment.
"Set start page" reloads the page that is on air.
set_home goes through update_block_properties → try_apply_live_url → cef_pages::load_url, which sends Page.navigate to the URL the page is already showing. Pinning the page the operator just reached therefore reloads it live. That loses SPA or form state, and can break a page reached through a one-time redirect.
Storing the start page should not navigate when the page is already on that URL.
| let mut flags = network_checks(&existing); | ||
| flags.extend(fake_media_switches()); | ||
|
|
||
| let port = debug_port(&mut flags, setup.debug_port); |
There was a problem hiding this comment.
Default-on widening of the attack surface. Please call it out in the PR body.
Before this PR, nothing opened Chromium's debug port unless an operator configured it. Now every instance that runs an HTML source opens the full DevTools protocol on loopback, even with no authentication and no cef.debug_port. Any other local user or process sharing the network namespace (e.g. host-network containers) can then run arbitrary JS in the page, read file:// and read every cookie, with no credentials.
The design needs it (it is how every page gets guarded), so I'm not asking to drop it. But it is a behaviour change operators should know about, and the PR description should state it plainly.
| // process could take a free port in between; then pages go | ||
| // unguarded and each says so when it starts. | ||
| let port = match configured { | ||
| Some(port) => port, |
There was a problem hiding this comment.
A configured port 0 is accepted.
If STROM_CEF_DEBUG_PORT or cef.debug_port is 0, configured is Some(0) and this pushes remote-debugging-port=0. Chromium then picks a random port while Strom records and dials 0. Page guards never attach and remote control always reports "not answering". The branch that parses the port from the flags already rejects 0; this branch (or config validation in config.rs) should do the same, with a clear error.
…tart page Every string property of a steered cefsrc read back as the page URL, not just url: name, context-cache-path and the rest. A client reading the element's properties and writing them back would have written that URL into all of them. shown_string_property maps url only. Remote control's Set start page wrote the URL through the live path, which sent Page.navigate to the address the page was already on and so reloaded the page on air: a single-page app's state or a form was lost, and a page reached through a one-time redirect could break. set_home now notes that the page is already showing the URL; the next live write of exactly that URL records it without navigating, and the note is cleared after the write either way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With STROM_CEF_DEBUG_PORT or cef.debug_port at 0, Strom pushed remote-debugging-port=0: Chromium picked a random port while Strom dialled 0, so no page was guarded and remote control always reported not answering. It is now refused with a warning, like the bare flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What this is
An HTML source that is a block rather than raw elements, and a way for an
operator to click in the page it renders.
A page behind a login shows its login screen for as long as the flow runs,
because the browser is on the server and nobody can reach it. This adds a route
to that browser: the operator opens a link, sees the page exactly as it is on
air, and clicks and types into it — logs in, clears a consent dialog, picks a
tab. Nothing about the session is copied anywhere; the browser that is
rendering is the browser that gets authenticated.
Warning
The debug port is now open by default. Before this PR, no Chromium debug port was opened unless an operator configured one. Now every Strom that runs an HTML source opens the full Chrome DevTools Protocol on a loopback port, even with no authentication and no
cef.debug_portset. Strom needs it to guard every page and for remote control.Loopback is not a credential. Any other local user or process that shares the network namespace can connect without authenticating, for example another container on host networking. Through the port it can run arbitrary JavaScript in the page, read
file://URLs, and read every cookie, including the sessions of pages an operator logged in to.Run Strom where nothing untrusted shares its network namespace: its own container on its own network, which is already the rule for a shared Strom. Do not publish or forward the port.
The HTML Input block
builtin.html_input. URL, viewport width and height, framerate, and whether thepage's audio comes out as a second pad.
cefsrcsizes its browser from the caps negotiated downstream, so the viewportbelongs to the block rather than to whatever follows.
cefdemuxis built onlywhen audio is asked for, since splitting the audio back out of the video buffers
is all it does. Audio-only still renders the page and drops the frames, because
the audio arrives as metadata on them.
Raw
cefsrcpipelines keep working. The block spares the operator the caps, andgives Strom a name for "this HTML source" — which is what the link endpoint
needs in order to hand out a link for one page rather than for every page.
The URL is live:
cefsrcloads a newurlinto a running browser. It does notflag the property
GST_PARAM_MUTABLE_PLAYING, so the generic live-write pathused to refuse it;
html_input::try_apply_live_urlwrites it directly. Theviewport is not live, and says so.
An HTML source renders
http,httpsanddataURLs only. It is anallowlist, not a list of what to refuse:
view-source:file://reaches thefilesystem without starting with
file:, and Chromium keeps its own pagesbehind
chrome:,devtools:andchrome-extension:. A bare address such asexample.comis read ashttps://. The samenormalize_urlcheck holds whenthe block is built, when its URL changes on air, and in the remote control
proxy.
Remote control
With authentication configured, Strom passes
remote-debugging-portto CEFon a free loopback port. Nothing outside Strom connects to it: links go
through Strom's proxy, and Strom guards every page through it (below), so
there is no instance-level opt-in.
cef.debug_port(orSTROM_CEF_DEBUG_PORT) only pins the number. Whether a block hands out linksis its Remote Control switch.
persist-session-cookiesrides along, becausewithout it a login lands in a cookie Chromium keeps in memory only and the
next flow start is logged out again. Chromium binds the port to loopback and
it stays there.
Remote control requires Strom's authentication to be configured.
auth_middlewarepasses everything when authentication is off, so minting alink would take no credentials at all. With no
STROM_ADMIN_USERorSTROM_API_KEYthe debug port is not opened, and the first HTML source builtsays once that its page is not guarded.
The link carries the page and nothing else
The Chrome DevTools Protocol behind the debug port is full control of the
browser process: arbitrary JavaScript, navigation to
file://, every cookie inthe profile, an HTTP client inside the network. Handing that to whoever the
operator wants to have log in is not acceptable, so a link does not hand it out.
A link opens Strom's own page (
backend/static/remote-control/), served fromunder the key. It shows the page through
Page.startScreencast, sends mouse,touch and keyboard input back, and has back, forward, reload and home. Strom
terminates the WebSocket and forwards only:
Every navigation and history command is rebuilt with only the parameters it
needs.
Page.reloadalso takesscriptToEvaluateOnLoad, which is arbitraryJavaScript, and
Page.navigatetakes aframeId.Page.navigategoes only toan address
normalize_urlaccepts.Two commands are Strom's own, not Chromium's:
Strom.goHome: the proxy turns it intoPage.navigateto the block's URL.Strom.setHome(the pin button): makes the page the operator is on theblock's URL, through
update_block_propertieswith the same check, andbroadcasts
FlowUpdated. An operator who has logged in or clicked through tothe right page sets it as where the source starts.
What a link reaches, and what its warning now says: the page's picture and
input, any
http,httpsordataaddress, and the block's URL. From insidethe network an
httpaddress reaches whatever the server can, and adataaddress runs script of the holder's choosing. It never reaches the filesystem
or Chromium's own pages.
Each session opens with a
Strom.contextmessage from the proxy carrying theblock's name and the home URL, so the page's header says which source is under
control. The page names that source and nothing else: no product name and no
flow name, so it can be handed on as it is or put behind another product, such
as Open Live, with a reverse proxy. Its socket is one path along from wherever
the page was loaded, so it works under any path prefix. A session that cannot
open, because the block's flow is not running, gets
Strom.unavailablewiththe reason and the page shows it.
On a phone: a tap is a click, a drag scrolls, and a keyboard button focuses a
hidden field whose text goes over as
Input.insertText.Nothing the client sends goes to Chromium as it arrived: an allowed command is
rebuilt from its parsed
id,methodandparams, so anything else on themessage (a top-level
sessionId, say, which would aim it at another session)stays behind. Everything else is answered with a CDP error against the
command's own id, so a client gets a refusal rather than a socket that swallows
things. The filter is
in the proxy, not in the page: the page we serve is only the first client of the
link, and the filter is what makes it safe to hand to a second one.
Terminating the socket is also what keeps Chromium's
Origincheck(
--remote-allow-origins) out of the picture: our connection to Chromiumcarries no origin.
Why the filter cannot be added later
DevTools needs exactly the domains the filter exists to refuse — Runtime,
Debugger, DOM, Network. Serving DevTools and filtering the protocol are two
modes, not one with a switch. Shipping the unfiltered one first would mean
never being able to restrict it without taking the feature away. The feature's
purpose needs a picture and an input device, nothing more, so that is the
default and the only thing a link does unless the operator opts out.
Full DevTools, opt-in
cef.full_devtools = true(orSTROM_CEF_FULL_DEVTOOLS=1) serves Chromium'sown DevTools application instead, with the protocol unfiltered. CEF serves that
application itself, so nothing is fetched from the internet. The
/ui/assetroute exists only in this mode.
In this mode a link is control of the host, and it is instance-wide: one
CEF process serves every
cefsrcin an instance, and the unfiltered protocolcan attach to any page in it, so a session started on one page reaches all of
them. Separate browser contexts (below) do not change that, so full DevTools
must stay off on a Strom shared between customers. The docs and the
warning the API returns say this in so many words, and the two modes carry
different warnings (
SCREENCAST_CONTROL_WARNINGvsREMOTE_CONTROL_WARNING),so no client can present one as the other.
The link is a capability
Minting needs Strom's authentication. The link itself is one opaque key — no
API token, no Chromium target id, no address or port. It is meant to be pasted
into a browser or read off a phone screen, so anything in it is something the
operator cannot avoid handing over with it.
The key is the credential for everything under
/devtools/<key>: the page andits socket (plus the DevTools files in full mode). It expires after 30 minutes
of disuse. Opening the link starts that over, and so does the operator's input
in a session on it (clicks, keys, navigation and Strom's own commands), so a
session being worked in does not expire. Screencast upkeep such as frame acks
does not count, so a tab left open and only watched is closed when the time
runs out. The session reports use to the link table every 15 s and sweeps it
at the same time, so expiry ends a session even when nothing else touches the
table. The
idis not a credential — it is what revocation takes,so a link can be killed without anyone keeping the key. Expiry and revocation
both end sessions that are already open, not only the next one.
A link is for the block, not for one page of it. A session resolves the
block's page when it opens, so the same link works across a restart of the
flow, and the listing names links by flow and block. When the session opens,
on every 15 s check and on Home, it reads the block's home address and Strict
Network Access, so a change made in the UI is not missed. A session ends when
the block's page closes, wherever it is looking, and when the flow stops. The
start page cannot be set while a popup is on show: the popup's one-time login
address would go on air and be saved.
Which page is a block's
Chromium says two things about a page: its target id and its current URL.
Matching blocks to pages by URL broke in both directions. A page that had
logged in or redirected to another site was reported as "not rendering a page
yet" while it was on air. Two blocks on one URL, such as two HTML Input blocks
left on the default address, were refused as ambiguous.
The target id, unlike the URL, stays the same for the whole life of a page,
across navigations to any site. So every page is now named when it is born.
With the debug port open (that is, with authentication configured), each
cefsrcstarts onabout:blank#strom-<token>,with a random token nothing else knows. Once Chromium lists a page on that URL,
Strom records its target id against the block or element and points the
element at its real URL. The blank start is Chromium's initial empty document,
which the first navigation replaces. The page guard (below) makes Chromium
keep it as a history entry, so the guard clears the history once the page has
left the start and finished loading. A URL set on air while the page is being
born is the one it loads: live writes and the birth go through one lock. If
Chromium has not listed the page 5 s after its element started, Strom loads
the URL anyway rather than leave a blank page on air. Reading the element's
urlwhile its page is being born gives the URL it is about to load, never themarker.
This needs nothing from gstcefsrc beyond its
urlproperty, so it works withthe upstream plugin as well as Strom's build. Without a debug port nothing
changes: the element loads its URL straight away. The URL matching and the
409 for two blocks on one URL are gone. The target endpoint traces a page
back to the block it was born for, and a raw
cefsrcpage has an owner butno Remote Control switch, so it is refused.
Guarding every page
Strom's gstcefsrc patches fix some of what an offscreen browser does wrong,
and Strom cannot tell from the plugin which of them it has. The debug port is
therefore always open (on loopback; authentication gates minting links, not
who can reach a loopback port), and every named page gets a guard: a
DevTools session of Strom's own for the page's life. It intercepts file
choosers, denies downloads, makes
print()a no-op, dismissesalert,confirmandprompt, and refusesgetDisplayMedia. The last two arereplaced on the prototype before any page script runs, so the page cannot
take them back, and the guard auto-attaches to frames from other sites and
gives them the same. These hold only while a session is attached, so it stays
attached, and it is in place before the page leaves its blank start.
Screen capture needed this on every build: gstcefsrc runs Chromium with
enable-media-stream, which grants media requests without asking theelement, so the permission handler the old offscreen-handlers patch installed
never ran.
getDisplayMediawas granted on upstream and on Strom's buildalike. In
strom-fullthat is an empty Xvfb screen; on a desktop it is theoperator's real one.
gstcefsrc also loses its browser once a popup closes (below), and then
ignores a new
url. From birth on, an on-air URL therefore goes to the pageover DevTools instead of through the element, on every build.
Popups
A login that opens a window - "Sign in with...", an OAuth consent screen - is a
target of its own, and a session used to show only the page its link was
minted for, so clicking "Sign in" appeared to do nothing. With the Chrome
runtime a popup is not painted into cefsrc's video, so it never goes on air.
The proxy now watches the browser's targets (
Target.setDiscoverTargetsonthe browser endpoint) and follows the ones the session's page opened, through
any number of popups. A new popup is shown straight away; when it closes -
an OAuth window closes itself when done - the session goes back to whoever
opened it. The page lists the windows as tabs, and a popup can be closed from
there.
Strom.switchPageandStrom.closePagename a target, and the proxyrefuses any target that is not the session's page or opened from it.
A login that runs entirely in a popup (Microsoft Teams, for one) leaves the
popup logged in and the source's own page still on the sign-in screen. Each
popup tab has a button that loads the popup's page into the source and closes
the popup:
Strom.adoptPagenames the popup's target only, the proxy looks theaddress up in the target info it already follows, refuses anything but the
session's own popups, and puts the address through the same scheme and Strict
Network Access checks as any other navigation. Setting it as the start page
stays a separate step, so a one-time address with a login token in it is not
saved by accident.
The target listing leaves out anything with an opener, since a popup belongs
to the page that opened it.
A gstcefsrc bug this surfaced
A popup is created with the element's own CEF client, so its
OnBeforeClosearrives in the element's handler, which set
src->browserto null withoutchecking which browser closed. From then on the element had lost its browser
while the page kept running:
urlwas accepted by Strom and silently ignored by cefsrc(
if (src->browser) LoadURL(...)), so URL changes and "set as start page"stopped working after any OAuth login;
network - until the process exited.
docker/gstcefsrc/popup-close.patchignores a close for any browser but theelement's own, and the gstcefsrc Dockerfile applies it. Every page that opens
and closes a window hits this, remote control or not.
One browser context per HTML source
Upstream gstcefsrc creates every browser with a null request context, which is
the global one, so every HTML source in a Strom process shared one cookie jar,
one local storage and one HTTP cache. Measured in
strom-full: flow B read thesession cookie, persistent cookie and localStorage that flow A's page had set.
On a Strom shared between customers, a login made for one customer was a
login for all of them, remote control or not.
docker/gstcefsrc/isolated-context.patchadds threecefsrcproperties, alloff by default:
isolated-context: create the browser in its ownCefRequestContext.Popups opened by the page inherit it.
context-cache-path: persist that context. Chromium accepts a profile onlyas a direct child of the root cache path, and silently keeps one in memory
anywhere deeper.
persist-session-cookies: Chromium's flag reaches only the global context;an isolated one carries its own setting.
A persisted context initializes asynchronously, and
CreateBrowserSyncin itreturns null, which gstcefsrc then dereferenced. The browser is now made in
OnRequestContextInitialized, and a null browser is an element error insteadof a crash.
When the plugin has the property, Strom turns isolation on for every
cefsrc:browser_profileproperty names a profile several blocks share.cefsrcelements get one keyed by flow and element id, under theirown prefix, unless the flow sets
isolated-contextitself. This is how OpenLive drives HTML sources, and how DSK graphics are made.
Without the property, Strom warns for every HTML source it builds, and a
filtered link's warning adds
SHARED_CONTEXT_WARNING: a login made through thelink reaches every HTML source in the instance. Whether the plugin isolates is
checked when a link is minted, when the plugin is already loaded.
With the patched plugin, HTML sources of different customers do not see each
other's sessions, and a filtered link reaches only its own page and that
page's profile. That is not enough to share one Strom between customers who
may be hostile to each other; see Sandbox
below.
Strict Network Access
An HTML source could be pointed at the server itself. Through the block's
URL, remote control's address bar or the pin button,
http://127.0.0.1:9222/json/listrendered the debug port's listing into the source: every page in the process,
with its full URL, other customers' included. On a cloud VM
http://169.254.169.254/would render the instance metadata. Chromium's LocalNetwork Access does not check a top-level load, so nothing stopped it. Found
by hand in the dev image.
The HTML Input block gets Strict Network Access, on by default. With it
on, the URL, a live URL change,
Page.navigateandStrom.setHomerefuse ahost on the server or its local network: loopback, RFC 1918, link-local,
100.64/10, unspecified and broadcast, IPv6 loopback, unique-local andlink-local, IPv4-mapped IPv6, and
localhost/*.localhost. The address isparsed the way Chromium parses it, so
127.1,0x7f000001and2130706433are loopback too. Off is for a Strom on the operator's own machine rendering its
own local pages. Its description says, in capitals, that a system letting
customers edit flows must never expose it.
What the page itself requests is Chromium's to check.
docker/gstcefsrc/strict-network.patchaddsstrict-networktocefsrc:other with allow. Offscreen CEF would otherwise only ignore the prompt.
enable-featurescan be repeated inGST_CEF_CHROME_EXTRA_FLAGS. The listis split on commas, so a feature list could not be passed at all before.
Strom now always enables
LocalNetworkAccessForNavigations,LocalNetworkAccessChecksWebSocketsandLocalNetworkAccessChecksWebTransport.It sets
strict-networkfrom the block, and on by default for rawcefsrcelements.
WebRTC UDP is not restricted for strict sources. An earlier build set
webrtc.ip_handling_policy=disable_non_proxied_udp;strom4drops it. A pagecan only make the browser send STUN connectivity checks, not data of its own,
while blocking UDP broke most WebRTC pages, which are a real use as sources.
Probing internal UDP ports is the exposure left, and
docs/HTML_RENDER.mdsays so.
A hostname that only resolves to an internal address still gets past the
check on the page's own URL. That, and anything Chromium gets wrong, is the
deployment's to close (below).
Devices, dialogs and console output
Left to CEF, a browser with no handler of its own did real damage, measured
in the
strom-fulldev image: a click on<input type=file>built a GTK filechooser inside Strom on Xvfb and aborted Strom with every flow in it (exit
133), a page's download was written to
/root/Downloadswithout a click, andwindow.print()never returned. The guard above now refuses all of it.A page's
console.logreached the container log twice, and a page logging ina loop filled the disk. Chromium logs every console message at INFO,
console.errorincluded, so the entrypoint's CEF log severity now defaults towarning, and Strom sets the same default for native runs
(
GST_CEF_LOG_SEVERITYstill wins). Measured on upstream with a page loggingon all four levels for three seconds: 1200 lines per level at the old
default, none at warning.
Camera and microphone. Pages that will not start without them (a video
call joined to be watched) were refused outright. Strom now runs Chromium with
use-fake-device-for-media-stream, playing a silent WAV and a black Y4M frameit writes into the CEF cache directory at startup. Chromium's own fake devices
beep and show a green card, which the others on a call would hear and see.
The switch goes in even when Strom cannot write its files, so a page gets the
test tone rather than the server's capture cards.
What each gstcefsrc patch still buys
With the guard in place, each patch was measured by building gstcefsrc
locally with fewer of them and running the same checks in
strom-fullagainst each build:
enable-media-stream). A right click through remote control does not take Strom downenable-features, so WebSocket and WebTransport are not checkedBecause closing a page the element has lost corrupts memory, Strom no longer
closes such a page on stop. With popup-close there is none; on upstream
Strom logs that it is still running.
Chromium's sandbox is off in
strom-fullMeasured in the dev image, not introduced by this PR:
docker/strom-full/entrypoint.shpassesno-sandboxin every mode. Renderers run as root, in Strom's network namespaceno-sandbox, as rootRunning as root without --no-sandbox is not supportedno-sandbox, non-root userFATAL: No usable sandbox!Docker's default seccomp blocks the user namespaces it needs, and the FATAL takes Strom down (exit 133)So everything above holds against a page that plays by the browser's rules,
and nothing does against one that exploits a Chromium bug: that is code
running as root in the Strom that serves everyone.
docs/HTML_RENDER.mdnowstates the rule for customers who must not reach each other: one Strom
container per customer, no
--network host, containers unable to reach eachother, and
169.254.0.0/16dropped (IMDSv2 with hop limit 1 on AWS). Switchingthe sandbox on needs a non-root image and a Chromium seccomp profile, which is
a PR of its own.
Publishing the patched plugin
The patches only reach an image through the
gstcefsrc-depsrelease, whosearchives were named by CEF version only. A patched build under that name would
have replaced
gstcefsrc-144.0.21-*, the binaries every existingstrom-fullimage downloads.
build-gstcefsrc.ymltherefore takes abuild_suffix, andrefuses to overwrite a published asset (
overwrite_files: false). It printseach archive's sha256.
version.txtin the archive records the gstcefsrc refand the patches.
Status:
docker/strom-full/Dockerfiledownloadsgstcefsrc-144.0.21-strom5(run 37004818856): popup-close, isolated-contextand strict-network. Each archive is checked against a per-architecture
sha256 before it is unpacked, and a missing asset fails the build. Before
switching, the arm64 archive's sha256 was checked against the release digest
and the published plugin ran the suite in
strom-full. The earlier buildsstrom1–strom4, and122.1.13and144.0.11, which no tag uses, aredeleted from the release;
138.0.56,144.0.12and144.0.21stay for thereleased tags that download them.
Cleaning up the CEF cache
A profile used to stay in the cache root after its block was gone, holding
the cookies of whatever it was logged in to. Deleting a flow now removes its
sources' own profiles at once. At startup, before any flow runs, Strom removes
every block, element and named profile no stored flow uses. Startup is the
only point where that is safe for a block taken out of a flow that keeps
running. Named profiles go only at startup, since another flow may share one.
Chromium locks the cache root with a
SingletonLocknaming host and pid, andtreats a lock from another host as held. After a hard kill under a different
hostname (a laptop changing networks is enough) every
cefsrcfailed with abare "Failed to start". The cache root is per data directory, so Strom clears
a lock from another host before CEF starts. A lock from this host is left to
Chromium, which checks the pid itself. In
strom-fullthe entrypoint wipes thecache at container start anyway; this matters for a cache that persists.
In the UI
A live text property went out on every keystroke, so typing a URL into a
running HTML source loaded every prefix of it. Free-text live properties now
settle: they are sent once the value has not changed for 700 ms. Sliders,
checkboxes and dropdowns keep the 80 ms debounce with a leading edge.
A QR code and an "Open" button in the block's properties, and double-click on
the node, the way the compositor, AES67, NDI, WHEP and WHIP blocks already work.
The UI shows the warning the server returns with each link, which differs by
mode and by whether sources share a browser context, and keeps the link in
memory rather than passing it through local storage, which in a browser is on
disk. Live property writes are debounced per flow, block and property; block
ids repeat across copied flows.
remote_controlon the block gates it, off by default. The server refuses tomint without it. It can be switched on while the flow runs, which is the whole
point — an operator turns it on to intervene in a page that is already on air.
What was measured
Against
eyevinntechnology/strom-fullwith CEF 144 (Chrome 144.0.7559.248,arm64), driving a real
cefsrcthrough this proxy with a locally built Linuxbinary mounted into the image. This was done before the protocol filter
landed, i.e. in what is now the full-DevTools mode:
common.jsNetwork.setCookie,Input.dispatchMouseEvent,Input.dispatchKeyEventPage.startScreencastScreencast bandwidth, measured separately: an animated 1080p page costs
~4.8 Mbit/s at 30 fps; capped to 720p/q50/every second frame, ~1 Mbit/s. A
static login page sent one frame in ten seconds — the screencast is
change-driven, and the login case is the cheapest there is. Frames are
ack-gated, so a slow link costs frame rate rather than an unbounded queue.
Also confirmed empirically: a per-element
chromium-debug-portcannot give eachcefsrcits own port. CEF initializes once per process, and only the lastelement's value takes effect. This is why the setting is instance-level.
The default, filtered mode, measured the same way after the filter landed
(arm64, same image), with a flow built from the block with
remote_controlon:/devtools/<key>/devtools/<key>/ui/*(DevTools assets)/devtools/<key>/wsRuntime.evaluate,Page.navigate,Network.getAllCookies-32601against the command's idInput.dispatchMouseEventStrom.contextwith flow, block and home URL (the flow name has since been dropped)Page.getNavigationHistory,Strom.goHome,Page.reloadPage.navigatetohttps://Page.navigatetofile:,view-source:file:,chrome:Strom.setHomeurlupdated, page loadedurlchanged through the API on a running flowurlset tofile:on a running flowPage.startScreencastthrough the filterid, then open the pathPages named at birth, at
614e7d5instrom-full:dev-e19ffe7(arm64)with a locally built Linux binary copied in:
https://example.com/through its linkcefsrcwith nourlabout:blankpages left overMeasured before, by hand, with CDP alone: a target id is unchanged after navigating
to another site and then to a
data:URL;about:blank#...as the first URLleaves no history entry once the page navigates.
The guard, on upstream and on Strom's gstcefsrc, at
59f71c6instrom-full:latest(upstream gstcefsrc, noisolated-context) andstrom-full:dev-e19ffe7(Strom's build), arm64, locally built binary copiedin. A data page that calls
print(),alert(),confirm()and clicks adownload on load, with a file input and a button that opens a popup and closes
it 1.5 s later:
confirmfalse, title ticking/root/DownloadsStrom.unavailablewith the reasonThe port Strom picks, and page console output, at
7b9defcon upstreamgstcefsrc with the new entrypoint and no
cef.debug_port: Strom opened theport on a free loopback port, a link minted, and the guard was in place
(
print()returned). A page logging on all four console levels for threeseconds put 1200 lines per level into the log under the old entrypoint and
none under the new one; Strom now sets the same
GST_CEF_LOG_SEVERITYdefault for native runs.
The two plugins differ exactly where the popup-close patch says they would:
without it the element ignored the URL change and left its page running, and
Strom's guard covered both.
Popups, against CEF 144 in
strom-full(arm64):Strom.switchPage/Strom.closePageon another source's pageurlchangedpostMessageto opener, closes)Also measured: CDP's
mouseWheeltakes the DOM's sign (positivedeltaYscrolls down), which the first version of the page had inverted.
The first version of the page was tried by hand in a desktop browser and on an
iPhone against a real page, where taps, scrolling and typing did not work. The
touch and keyboard handling that fixes that has not yet been tried on a
phone.
Tests
Run locally at
fd98c2f(macOS arm64):cargo test --lib(742 passed, 4ignored),
openapi_test(snapshot updated: the link summary names flow andblock instead of a URL, the 409 is gone, descriptions reworded),
html_remote_control_property_testandpipeline_lifecycle_test; the frontendpropertiestests.cargo clippy --all-targetsis clean, native and wasm32.The full suite was not run locally at this head; CI runs it.
New tests that guard this round's fixes, each failing without its fix:
cef_pages::a_url_set_on_air_during_birth_is_the_one_loadedand the twobeside it (a fake element with a
urlproperty stands in forcefsrc),a_page_being_born_shows_the_url_it_will_load,a_stopped_flow_forgets_its_pages_and_no_one_elses,the_same_block_id_in_two_flows_keeps_two_pending_valuesin the frontend,and
cef_flagstests for the flag order and the debug-port rules. The guardand the session changes have no CI test: they need a real CEF. The table
above is how they were checked. Not checked by hand: the pin button greyed out
on a popup in the page, and the refusal of
Strom.setHomefrom a popup.Run locally on an earlier head (macOS arm64, after rebasing onto
main): the fullcargo test, 42 test binaries, all green. That includespipeline_lifecycle_test,liveaudiorouter_testandhtml_remote_control_property_test, which had notrun on this branch in CI before. After the last refactor I re-ran the
devtoolsandhtml_inputunit tests (68).cargo clippy --all-targetsis clean.New since the last review:
a_strict_source_does_not_reach_the_server_or_its_network,a_block_is_strict_unless_switched_off,filter::a_strict_source_is_not_navigated_onto_the_server,and in the frontend
typed_text_goes_out_once_the_typing_stops(fails withoutthe fix: the first keystroke went out) and
a_slider_still_goes_out_on_the_first_change.Also:
filter::only_the_parsed_command_is_forwarded,filter::only_the_operator_keeps_a_link_alive,working_in_a_session_keeps_its_link_alive(the link hold, not the pump thatcalls it),
without_isolation_the_warning_says_a_login_reaches_every_source,and the profile-directory tests in
html_input, includinga_raw_cefsrc_gets_a_profile_no_block_or_name_can_land_on.New in
614e7d5:cef_pagesunit tests for the page registry, andplacementtests includingtwo_blocks_on_the_same_url_each_get_their_own_pageand
a_page_is_found_wherever_it_has_navigated. They run against theregistry, not a browser; the end-to-end check is the table above. At this head
I ran
cargo test --lib(733 passed),openapi_test(snapshot updated for theremoved 409 and two reworded descriptions),
html_remote_control_property_testand
pipeline_lifecycle_test; clippy is clean.New in
675fe4e, incef_profiles:startup_keeps_the_profiles_flows_use_and_removes_the_rest,a_deleted_flow_takes_its_own_profiles_and_nothing_else,a_lock_from_another_host_is_cleared_and_one_from_this_host_is_notandthe_host_is_everything_before_the_pid. They call the module's functionsagainst a temporary cache root. The calls from
load_from_storage,delete_flowandmainare not covered: they read the process-wideGST_CEF_CACHE_LOCATION, which a test cannot set without racing the othersin its binary.
CI runs the full suite. Specifically relevant: the
api::devtoolsunit tests(including the method filter, the parameter rewrites,
Strom.goHomeignoring anyaddress the client sends, and refusal-by-id), the
html_inputunit tests,html_remote_control_property_test, and the OpenAPI snapshot, which was updateddeliberately.
cargo clippyclean for native and wasm32.html_remote_control_property_testnow also refusesfile:,view-source:andchrome:on a running flow throughupdate_block_properties; without thecheck the rejection reason is a pipeline write failure and the test fails.
html_remote_control_property_testis a real guard: with the special caseremoved, the switch comes back rejected and it fails. A companion test pins the
viewport as not live so the two cannot drift into each other.
Measured by hand in
dev-c3e470c(arm64, Docker Desktop, bridge network), with adata:probe page in two flows, one strict and one loose:fetchto127.0.0.1:9222,localhost:8080,169.254.169.254fetch, XHR,img,window.opentofile:127.0.0.1:9222fetchtohttps://example.com(control)http://127.0.0.1:9222/json/listA
data:page is not a secure context, so Chromium blocks its local requestswithout asking. The strict-network permission answer only decides for
httpspages, and that path is not yet tested. Neither are navigation, WebSocket
(the result above does not tell LNA from the debug port's Origin check) and
WebRTC with the new patch.
Not covered by a test that runs in CI: everything involving a real
cefsrc,including
try_apply_live_urlactually moving a running browser, popupfollowing, the popup-close, isolated-context and strict-network patches, and the pump
calling the link hold. The isolation for the block was verified by hand
against CEF 144 in
strom-fullwith the patched plugin: two flows isolated,one profile name shared across flows, session and persistent cookies and
storage kept across a restart, and a popup seeing its opener's context. The
raw-element path uses the same function but has not been run against a real
plugin. The target-family logic that decides
which popups a session may reach is unit-tested in
devtools::pages.A CI test needs one, which #810 is adding; the natural guards to add there are
that a minted link resolves to the browser its block is rendering, and that a
method outside the allowlist is refused against the live browser.
Not in this PR
and the debug port are not. It matters in full-DevTools mode, where it is the
whole blast radius.
strom-fullentrypoint clears the CEF cache when the container starts. So a login
survives a flow restart, not a container restart.
internal address as the page's own URL. Both are left to the deployment
rule in
docs/HTML_RENDER.md.before the process is killed can still be lost.
strict-networkanswer for anhttpspage, navigation, WebSocket and WebRTCwith the patch, and the raw-
cefsrcisolation path.remote control's filter does not forward drag events.
about eight months behind 154), and renderers run unsandboxed as root in
strom-full. gstcefsrc master has 22 commits since the pinned ref,including leak fixes and new caps towards
cefdemux. A PR of its own, runagainst the same local suite before anything is published.
has support for but downloads through the component updater the
entrypoint switches off.
STROM_CEF_WIDEVINE=1leaves it on; Google shipsthe Linux CDM for x86_64 only, and this build has no AAC. Not yet tried on
amd64.
SingletonLockfrom another host is cleared, which is wrong iftwo Stroms on different hosts share one cache directory. Strom's cache is
per data directory, so that takes a shared volume used by two containers at
once.
🤖 Generated with Claude Code