From 811beee6131047a4d0783edf2ca4cae036706485 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 21:25:41 -0800 Subject: [PATCH 1/4] chore(quest): claim quest/m0/nightly-green.md Quest-Claim: e5bfe46250aa1a7f From 63665fa8c3f40ce459050b028259d51702607828 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 22:11:01 -0800 Subject: [PATCH 2/4] fix(scene): browser sources load past a locked keyring, and the nightly runs the browser suite - The helper passes `--password-store=basic`: Chromium otherwise reads the profile's cookies only once the desktop keyring hands over its key, and a locked keyring with no one to answer its prompt held every network page in Starting (cookies_survive_a_restart on nixos2). - copy_and_cut_reach_the_clipboard reads the X clipboard where CEF copies natively (the X11 GPU path), and the helper's relay elsewhere, through the new Helper::relays_copies. - The nightly render job lets the runner create unprivileged user namespaces, which Chromium's sandbox needs; Ubuntu's AppArmor forbade them, and every helper aborted with SIGTRAP. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly.yml | 6 ++ README.md | 6 +- rs/moq-scene/src/browser/host.rs | 14 +-- rs/moq-scene/src/browser/mod.rs | 7 ++ rs/moq-scene/src/browser/protocol.rs | 14 +++ rs/moq-studio/tests/browser.rs | 148 +++++++++++++++++++++------ 6 files changed, 155 insertions(+), 40 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ea153b75..badeed47 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -36,6 +36,12 @@ jobs: - uses: ./.github/actions/nix + - name: Allow user namespaces + # The browser helper's Chromium sandboxes its processes in user + # namespaces, and aborts rather than run unsandboxed. Ubuntu's + # AppArmor lets only programs with a profile create them. + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Render run: nix develop --command just render-sw diff --git a/README.md b/README.md index 624c9c36..69923eb1 100644 --- a/README.md +++ b/README.md @@ -305,8 +305,10 @@ background, and its sound joins the mixes. Every page shares one browser profile, kept in `$XDG_DATA_HOME/moq-studio/browser` (`~/.local/share/moq-studio/browser` by default) rather than beside the scene file, so cookies and logins, such as an alert service's, survive a reload and -a restart of the studio. One studio at a time can use it: a second one's -browser sources fail saying so. Each page renders in a process of its own, so +a restart of the studio. They are not encrypted with the desktop's keyring, +so a locked keyring never holds a page back, and anything that can read your +home directory can read them. One studio at a time can use the profile: a +second one's browser sources fail saying so. Each page renders in a process of its own, so a page that crashes fails only its own source, with the reason, while the stream carries on. A crash of the browser itself fails every browser source. Either shows at once, since the browser's processes dump no core. diff --git a/rs/moq-scene/src/browser/host.rs b/rs/moq-scene/src/browser/host.rs index 378812f9..1581504c 100644 --- a/rs/moq-scene/src/browser/host.rs +++ b/rs/moq-scene/src/browser/host.rs @@ -118,13 +118,7 @@ fn serve() -> Result<(), String> { writer: Mutex::new(writer), painted: Mutex::new(painted), accelerated: matches!(start.raster, Raster::Gpu { .. }), - native_copy: matches!( - start.raster, - Raster::Gpu { - display: Display::X11, - .. - } - ), + native_copy: start.raster.native_copy(), }); // The studio shows why, rather than only that the helper exited. browse(&shared, start, reader, painted_tx).inspect_err(|reason| _ = shared.send(&Event::Fatal(reason.clone()), &[])) @@ -489,6 +483,12 @@ wrap_app! { Some(&"autoplay-policy".into()), Some(&"no-user-gesture-required".into()), ); + // Chromium otherwise reads the profile's cookies only once the + // desktop's keyring hands over its key, which a locked keyring holds + // behind an unlock prompt: every page that loads over the network + // stays Starting until someone answers it, and forever where no one + // can, as with no monitor attached. + command_line.append_switch_with_value(Some(&"password-store".into()), Some(&"basic".into())); match &self.raster { Raster::Gpu { render_node, display } => { let platform = match display { diff --git a/rs/moq-scene/src/browser/mod.rs b/rs/moq-scene/src/browser/mod.rs index c16dbcfb..169a2cfa 100644 --- a/rs/moq-scene/src/browser/mod.rs +++ b/rs/moq-scene/src/browser/mod.rs @@ -139,6 +139,13 @@ impl Helper { None } + /// Whether a page's Copy and Cut come back through [`Helper::copied`], + /// rather than reaching the X clipboard through CEF itself. + #[cfg(target_os = "linux")] + pub fn relays_copies(&self) -> bool { + !self.raster.native_copy() + } + /// Stop the helper, failing every page, and wait for it to exit, so the /// profile is saved and free for the next studio. The next page to start /// starts a new helper. diff --git a/rs/moq-scene/src/browser/protocol.rs b/rs/moq-scene/src/browser/protocol.rs index c46e4da5..6e15e615 100644 --- a/rs/moq-scene/src/browser/protocol.rs +++ b/rs/moq-scene/src/browser/protocol.rs @@ -73,6 +73,20 @@ pub(crate) enum Raster { Cpu, } +impl Raster { + /// Whether CEF's own copy reaches the desktop's clipboard: only through an + /// X display. + pub(crate) fn native_copy(&self) -> bool { + matches!( + self, + Raster::Gpu { + display: Display::X11, + .. + } + ) + } +} + /// The display server the GPU path connects to, the one the studio runs on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub(crate) enum Display { diff --git a/rs/moq-studio/tests/browser.rs b/rs/moq-studio/tests/browser.rs index e35729cc..fa05381b 100644 --- a/rs/moq-studio/tests/browser.rs +++ b/rs/moq-studio/tests/browser.rs @@ -325,23 +325,30 @@ async fn a_dropdown_draws_and_takes_clicks() { .await; } -/// Copy and Cut hand the page's selection to the studio for its clipboard, -/// and Cut deletes it from the page. A password field's text is never taken. +/// Copy and Cut put the page's selection on the clipboard, and Cut deletes it +/// from the page. A password field's text is never taken. #[tokio::test(flavor = "multi_thread")] #[ignore = "needs CEF and a Vulkan device; run by just render-sw"] async fn copy_and_cut_reach_the_clipboard() { local(async { - // A text box over the whole page, and a password field below it. The - // page turns yellow once the text box is selected, lime once it is - // typed in, and blue and white for the same on the password. - let html = "\ - \ + // A text box over the whole page, then a password field and a second + // text box below it. The page turns yellow once the first is + // selected, blue and white once the password is selected and typed + // in, and aqua and lime for the same on the second. This process's id + // tells its copies from whatever the X clipboard held before. + let id = std::process::id(); + let html = format!( + "\ + \ "; - let mut studio = Studio::new(&[("page", page(html))]).await; + oninput=\"document.body.style.background = 'white'\">\ + " + ); + let mut studio = Studio::new(&[("page", page(&html))]).await; studio.until_center([255, 0, 0, 255], &[]).await; let page = studio.sources.get("page").unwrap().page().unwrap().clone(); @@ -358,21 +365,7 @@ async fn copy_and_cut_reach_the_clipboard() { ctrl: true, ..Default::default() }; - let shift = Modifiers { - shift: true, - ..Default::default() - }; - let helper = studio.helper.clone(); - let copied = async || { - let deadline = Instant::now() + TIMEOUT; - loop { - if let Some(copied) = helper.copied() { - return copied; - } - assert!(Instant::now() < deadline, "the copy never came back"); - kio::clock::sleep(Duration::from_millis(30)).await; - } - }; + let mut clipboard = Clipboard::new(studio.helper.clone()); page.send(Input::Focus(true)); for down in [true, false] { @@ -390,7 +383,7 @@ async fn copy_and_cut_reach_the_clipboard() { key(b'A', ctrl); studio.until_center(YELLOW, &[]).await; page.send(Input::Copy); - assert_eq!(copied().await, "hello world"); + assert_eq!(clipboard.next().await, format!("copy {id}")); // Text typed after the copy shows only once the copy has come back. key(b'\t', Modifiers::default()); @@ -399,18 +392,111 @@ async fn copy_and_cut_reach_the_clipboard() { page.send(Input::Copy); page.send(Input::Text("x".into())); studio.until_center([255, 255, 255, 255], &[]).await; - assert_eq!(helper.copied(), None, "a password was copied"); + assert_eq!(clipboard.take(), None, "a password was copied"); - key(b'\t', shift); + key(b'\t', Modifiers::default()); key(b'A', ctrl); - studio.until_center(YELLOW, &[]).await; + studio.until_center([0, 255, 255, 255], &[]).await; page.send(Input::Cut); studio.until_center(GREEN, &[]).await; - assert_eq!(copied().await, "hello world"); + assert_eq!(clipboard.next().await, format!("cut {id}")); }) .await; } +/// Where a page's copies land: the X clipboard, where CEF copies itself, or +/// else the studio's, through the helper. +enum Clipboard { + Relay(Helper), + /// With the text it held when last read. + X11(Option), +} + +impl Clipboard { + fn new(helper: Helper) -> Self { + match helper.relays_copies() { + true => Self::Relay(helper), + false => Self::X11(x11_clipboard()), + } + } + + /// What a page copied since this was last asked, if anything. + fn take(&mut self) -> Option { + match self { + Self::Relay(helper) => helper.copied(), + Self::X11(held) => { + let text = x11_clipboard(); + if text == *held { + return None; + } + held.clone_from(&text); + text + } + } + } + + /// Wait for a page's next copy. + async fn next(&mut self) -> String { + let deadline = Instant::now() + TIMEOUT; + loop { + if let Some(text) = self.take() { + return text; + } + assert!(Instant::now() < deadline, "the copy never landed"); + kio::clock::sleep(Duration::from_millis(30)).await; + } + } +} + +/// The X clipboard's text, as a paste takes it: none with no owner, or one +/// offering no text. +fn x11_clipboard() -> Option { + use x11rb::connection::Connection; + use x11rb::protocol::Event; + use x11rb::protocol::xproto::{AtomEnum, ConnectionExt, CreateWindowAux, WindowClass}; + + let (connection, screen) = x11rb::connect(None).expect("the X display"); + let root = connection.setup().roots[screen].root; + let window = connection.generate_id().unwrap(); + connection + .create_window( + 0, + window, + root, + 0, + 0, + 1, + 1, + 0, + WindowClass::INPUT_ONLY, + 0, + &CreateWindowAux::new(), + ) + .unwrap(); + let atom = |name: &[u8]| connection.intern_atom(false, name).unwrap().reply().unwrap().atom; + let (selection, text, property) = (atom(b"CLIPBOARD"), atom(b"UTF8_STRING"), atom(b"MOQ_STUDIO_PASTE")); + connection + .convert_selection(window, selection, text, property, x11rb::CURRENT_TIME) + .unwrap(); + connection.flush().unwrap(); + loop { + if let Event::SelectionNotify(notify) = connection.wait_for_event().unwrap() + && notify.requestor == window + { + if notify.property == x11rb::NONE { + return None; + } + let value = connection + .get_property(true, window, property, AtomEnum::ANY, 0, u32::MAX) + .unwrap() + .reply() + .unwrap() + .value; + return Some(String::from_utf8(value).unwrap()); + } + } +} + /// A page that closes itself fails its own source and leaves the canvas, /// while the page beside it in the same helper keeps drawing. #[tokio::test(flavor = "multi_thread")] From 6d78325ea4442ddfca97ca77cf6590ef18db87b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 22:52:56 -0800 Subject: [PATCH 3/4] quest: nightly-green is done Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 1 - quest/m0/nightly-green.md | 26 -------------------------- 2 files changed, 27 deletions(-) delete mode 100644 quest/m0/nightly-green.md diff --git a/quest/m0/README.md b/quest/m0/README.md index c4f42cf0..51bff4d2 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -102,7 +102,6 @@ repository. - [Browser sources on the GPU under Mesa](/quest/m0/cef-headless.md) - shared-texture pages paint on Intel and AMD under Wayland, and a page that cannot paint fails loudly - [Browser sources on the GPU under X11 on NVIDIA](/quest/m0/cef-x11-nvidia.md) - the X11 GPU path paints on NVIDIA, or falls back to the CPU path loudly - [A browser helper exits with the studio](/quest/m0/browser-helper-exit.md) - no orphaned CEF helper after a crash, kill, or timed-out test -- [Nightly and the browser suite pass](/quest/m0/nightly-green.md) - render-sw, cookies, and the X11 clipboard test fixed at their causes - [GPU tests exit cleanly on NVIDIA](/quest/m0/gpu-tests-exit.md) - compositor tests no longer crash on exit on NVIDIA - [The Apple signing identity is available](/quest/m0/signing.md) - the Apple credentials packaging and the macOS camera need; Windows ships unsigned - [Packaging](/quest/m0/packaging.md) - a signed macOS app, a Windows installer, and a Flatpak for Linux diff --git a/quest/m0/nightly-green.md b/quest/m0/nightly-green.md deleted file mode 100644 index 816f85fa..00000000 --- a/quest/m0/nightly-green.md +++ /dev/null @@ -1,26 +0,0 @@ -# [M] Nightly and the browser suite pass - -## Goal - -The nightly workflow passes again, and the browser suite -(`rs/moq-studio/tests/browser.rs`) passes on every path it runs: GPU under -Wayland and X11, and the CPU path with no display. - -## Plan - -From [moq-dev/studio#73](https://github.com/moq-dev/studio/pull/73), picked -2026-11-07 (maintainer, /quest-complete), m0 by the rule that fixes join m0. - -Nightly has failed every night since 2026-11-04; it last passed on -2026-11-03. Known failures, each fixed at its cause: - -- `render-sw`: browser helpers exit on SIGTRAP - ([run 42235026329](https://github.com/moq-dev/studio/actions/runs/42235026329)). -- `cookies_survive_a_restart` fails on every path, including the CPU path on - `main`. -- `copy_and_cut_reach_the_clipboard` fails on X11: on that path a copy goes - to the X clipboard, but the test reads only the studio's own copy relay. It - first ran that far once #73 made X11 paint. - -Check the latest nightly run first, since other failures may have joined -these. From 03c845bfffab31427060430ed46748e89a7adea5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 23:00:15 -0800 Subject: [PATCH 4/4] fix(scene): run the browser helper without Chromium's sandbox on Linux Chromium's sandbox needs unprivileged user namespaces, which Ubuntu 24.04's AppArmor and a Flatpak deny, so every helper aborted with SIGTRAP there. The maintainer chose to disable it, as OBS does, over lifting the restriction on the CI runner, so the nightly's sysctl step goes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly.yml | 6 ------ README.md | 4 +++- rs/moq-scene/src/browser/host.rs | 5 +++++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index badeed47..ea153b75 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -36,12 +36,6 @@ jobs: - uses: ./.github/actions/nix - - name: Allow user namespaces - # The browser helper's Chromium sandboxes its processes in user - # namespaces, and aborts rather than run unsandboxed. Ubuntu's - # AppArmor lets only programs with a profile create them. - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Render run: nix develop --command just render-sw diff --git a/README.md b/README.md index 113926b7..b5a46fdb 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,9 @@ home directory can read them. One studio at a time can use the profile: a second one's browser sources fail saying so. Each page renders in a process of its own, so a page that crashes fails only its own source, with the reason, while the stream carries on. A crash of the browser itself fails every browser source. -Either shows at once, since the browser's processes dump no core. +Either shows at once, since the browser's processes dump no core. On Linux, +pages run without Chromium's sandbox, as in OBS, so a page that exploits a +browser bug can do anything you can: add only pages you trust. **Reload** in Sources restarts a page, failed or not. A page's dropdown lists, such as a `