From c324698d86ac9e16716349a322a1b63594aeefe2 Mon Sep 17 00:00:00 2001 From: hexuria <295542929+hexuria@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:30:51 +0800 Subject: [PATCH] a Bot can have its own screen, browser and profile on a computer several Bots share --- crates/box-cua/src/lib.rs | 42 +++++++ crates/box-exec/src/cua.rs | 85 +++++++++---- crates/box-host/src/active_tab.rs | 39 +++++- crates/box-host/src/bin/box-active-tab.rs | 6 +- crates/box-host/src/lib.rs | 17 ++- crates/grok-box/src/lib.rs | 91 ++++++++++---- docker/Dockerfile | 5 + docker/box-chromium | 9 ++ docker/box-refresh-extension | 23 ++++ docker/box-screen | 141 ++++++++++++++++++++++ docker/entrypoint.sh | 35 ++---- docker/websockify-nodelay.py | 27 +++++ 12 files changed, 440 insertions(+), 80 deletions(-) create mode 100755 docker/box-refresh-extension create mode 100755 docker/box-screen diff --git a/crates/box-cua/src/lib.rs b/crates/box-cua/src/lib.rs index d45bec8..08eb75e 100644 --- a/crates/box-cua/src/lib.rs +++ b/crates/box-cua/src/lib.rs @@ -167,6 +167,27 @@ impl CuaConfig { } } + /// This config on another of the box's screens: a Bot's own screen on a computer several + /// Bots share is its own X display (`box-screen`), with the same size as the shared one. + /// `None` is the box's own display. Only a display name (`:N`) is taken. + pub fn on(&self, display: Option<&str>) -> Result { + let Some(display) = display.map(str::trim).filter(|d| !d.is_empty()) else { + return Ok(self.clone()); + }; + let number = display.strip_prefix(':').unwrap_or(""); + let named = + !number.is_empty() && number.len() <= 3 && number.bytes().all(|b| b.is_ascii_digit()); + if !named { + return Err(CuaError::Invalid(format!( + "display must be :N, not {display:?}" + ))); + } + Ok(Self { + display: display.to_string(), + ..self.clone() + }) + } + /// True when CUA is enabled and the X display socket is present. pub fn capability_ready(&self) -> bool { self.enabled && display_up(&self.display) @@ -682,6 +703,27 @@ pub fn drag_waypoints( #[cfg(test)] mod tests { + + #[test] + fn a_request_may_name_another_screen_of_the_box() { + let shared = CuaConfig { + display: ":1".into(), + width: 1280, + height: 800, + enabled: true, + }; + assert_eq!(shared.on(None).unwrap().display, ":1"); + assert_eq!(shared.on(Some("")).unwrap().display, ":1"); + let own = shared.on(Some(":3")).unwrap(); + assert_eq!( + (own.display.as_str(), own.width, own.height), + (":3", 1280, 800) + ); + for bad in ["3", ":", ":1.0", ":abc", ":1234", "; rm -rf /"] { + assert!(shared.on(Some(bad)).is_err(), "{bad:?}"); + } + } + use super::*; #[test] diff --git a/crates/box-exec/src/cua.rs b/crates/box-exec/src/cua.rs index c3c62ba..b9d3003 100644 --- a/crates/box-exec/src/cua.rs +++ b/crates/box-exec/src/cua.rs @@ -21,6 +21,15 @@ use crate::AppState; #[derive(Debug, Deserialize)] pub struct ScreenshotQuery { pub format: Option, + /// Another of the box's screens (`:N`); see [`OnScreen`]. + pub display: Option, +} + +/// `?display=:N`: the action lands on that screen of the box, a Bot's own on a computer several +/// Bots share (`box-screen`). Without it, the box's own display, as always. +#[derive(Debug, Default, Deserialize)] +pub struct OnScreen { + pub display: Option, } pub fn map_err(err: CuaError) -> ApiError { @@ -67,11 +76,12 @@ pub async fn screenshot_handler( headers: HeaderMap, Query(query): Query, ) -> Result { + let cua = state.cua.on(query.display.as_deref()).map_err(map_err)?; if want_png(&headers, &query) { - let png = screenshot_png(&state.cua).await.map_err(map_err)?; + let png = screenshot_png(&cua).await.map_err(map_err)?; return Ok(([(header::CONTENT_TYPE, "image/png")], png).into_response()); } - screenshot(&state.cua) + screenshot(&cua) .await .map(|body| Json(body).into_response()) .map_err(map_err) @@ -79,81 +89,92 @@ pub async fn screenshot_handler( pub async fn click_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - click(&state.cua, &req).await.map(Json).map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + click(&cua, &req).await.map(Json).map_err(map_err) } pub async fn double_click_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - double_click(&state.cua, &req) - .await - .map(Json) - .map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + double_click(&cua, &req).await.map(Json).map_err(map_err) } pub async fn move_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - move_pointer(&state.cua, &req) - .await - .map(Json) - .map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + move_pointer(&cua, &req).await.map(Json).map_err(map_err) } pub async fn drag_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - drag(&state.cua, &req).await.map(Json).map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + drag(&cua, &req).await.map(Json).map_err(map_err) } pub async fn press_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - press(&state.cua, &req).await.map(Json).map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + press(&cua, &req).await.map(Json).map_err(map_err) } pub async fn release_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - release(&state.cua, &req).await.map(Json).map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + release(&cua, &req).await.map(Json).map_err(map_err) } pub async fn type_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - type_text(&state.cua, &req).await.map(Json).map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + type_text(&cua, &req).await.map(Json).map_err(map_err) } pub async fn key_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - key(&state.cua, &req).await.map(Json).map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + key(&cua, &req).await.map(Json).map_err(map_err) } pub async fn scroll_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - scroll(&state.cua, &req).await.map(Json).map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + scroll(&cua, &req).await.map(Json).map_err(map_err) } pub async fn recipe_handler( State(state): State, + Query(on): Query, Json(req): Json, ) -> Result, ApiError> { - run_recipe(&state.cua, &req) - .await - .map(Json) - .map_err(map_err) + let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?; + run_recipe(&cua, &req).await.map(Json).map_err(map_err) } #[cfg(test)] @@ -166,13 +187,15 @@ mod tests { assert!(want_png( &headers, &ScreenshotQuery { - format: Some("png".into()) + format: Some("png".into()), + display: None, } )); assert!(!want_png( &headers, &ScreenshotQuery { - format: Some("json".into()) + format: Some("json".into()), + display: None, } )); } @@ -181,8 +204,20 @@ mod tests { fn png_from_accept() { let mut headers = HeaderMap::new(); headers.insert(header::ACCEPT, "image/png".parse().unwrap()); - assert!(want_png(&headers, &ScreenshotQuery { format: None })); + assert!(want_png( + &headers, + &ScreenshotQuery { + format: None, + display: None, + } + )); headers.insert(header::ACCEPT, "*/*".parse().unwrap()); - assert!(!want_png(&headers, &ScreenshotQuery { format: None })); + assert!(!want_png( + &headers, + &ScreenshotQuery { + format: None, + display: None, + } + )); } } diff --git a/crates/box-host/src/active_tab.rs b/crates/box-host/src/active_tab.rs index 6abd452..6fdadb4 100644 --- a/crates/box-host/src/active_tab.rs +++ b/crates/box-host/src/active_tab.rs @@ -17,9 +17,32 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; -/// Where `box-active-tab` keeps the latest report. Removed when Chromium closes the port. +/// Where `box-active-tab` keeps the latest report for the box's own screen. Removed when +/// Chromium closes the port. pub const ACTIVE_TAB_FILE: &str = "/tmp/box-active-tab.json"; +/// Where the report for a screen is kept. Each of the box's screens has its own Chromium +/// (`box-screen`: a Bot's own screen on a computer several Bots share), and each Chromium starts +/// its own `box-active-tab`, which inherits that Chromium's `DISPLAY`. The shared screen +/// (`BOX_SHARED_DISPLAY`, `:1` by default) keeps [`ACTIVE_TAB_FILE`]; any other `:N` is +/// `/tmp/box-active-tab.N.json`. Anything that is not a display name is the shared screen. +/// +/// Not `BOX_DISPLAY`: a Bot's own Chromium is started with `BOX_DISPLAY` set to its own screen, +/// and its host would take that screen for the shared one. +pub fn file_for(display: Option<&str>) -> std::path::PathBuf { + let own = std::env::var("BOX_SHARED_DISPLAY").unwrap_or_else(|_| ":1".to_string()); + let number = |d: &str| { + let n = d.trim().strip_prefix(':')?.split('.').next()?.to_string(); + (!n.is_empty() && n.len() <= 3 && n.bytes().all(|b| b.is_ascii_digit())).then_some(n) + }; + match display.and_then(number) { + Some(n) if Some(n.clone()) != number(&own) => { + std::path::PathBuf::from(format!("/tmp/box-active-tab.{n}.json")) + } + _ => std::path::PathBuf::from(ACTIVE_TAB_FILE), + } +} + /// Native messaging caps a message to the host at 64 MiB; a URL is far smaller, and anything /// past this is not the extension speaking. const MAX_MESSAGE: u32 = 64 * 1024; @@ -185,6 +208,20 @@ mod tests { ); } + /// Each screen's Chromium reports to its own file; the box's own screen keeps the old one. + #[test] + fn each_screen_has_its_own_report() { + assert_eq!(file_for(None), Path::new(ACTIVE_TAB_FILE)); + assert_eq!(file_for(Some(":1")), Path::new(ACTIVE_TAB_FILE)); + assert_eq!(file_for(Some(":1.0")), Path::new(ACTIVE_TAB_FILE)); + assert_eq!( + file_for(Some(":3")), + Path::new("/tmp/box-active-tab.3.json") + ); + assert_eq!(file_for(Some("../../etc")), Path::new(ACTIVE_TAB_FILE)); + assert_eq!(file_for(Some(":3/../x")), Path::new(ACTIVE_TAB_FILE)); + } + #[test] fn a_message_longer_than_any_url_is_refused() { let input = (MAX_MESSAGE + 1).to_ne_bytes(); diff --git a/crates/box-host/src/bin/box-active-tab.rs b/crates/box-host/src/bin/box-active-tab.rs index bb83477..eac81b6 100644 --- a/crates/box-host/src/bin/box-active-tab.rs +++ b/crates/box-host/src/bin/box-active-tab.rs @@ -2,8 +2,10 @@ //! it records the page in front until the browser closes the port. See `box_host::active_tab`. fn main() { - let path = std::path::Path::new(box_host::active_tab::ACTIVE_TAB_FILE); - if let Err(error) = box_host::active_tab::serve(&mut std::io::stdin().lock(), path) { + // Chromium starts this host, so its DISPLAY is the screen that Chromium is on. + let display = std::env::var("DISPLAY").ok(); + let path = box_host::active_tab::file_for(display.as_deref()); + if let Err(error) = box_host::active_tab::serve(&mut std::io::stdin().lock(), &path) { eprintln!("box-active-tab: {error}"); std::process::exit(1); } diff --git a/crates/box-host/src/lib.rs b/crates/box-host/src/lib.rs index a07d235..2ab3051 100644 --- a/crates/box-host/src/lib.rs +++ b/crates/box-host/src/lib.rs @@ -12,7 +12,7 @@ use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::time::{Duration, Instant}; -use axum::extract::{Request, State}; +use axum::extract::{Query, Request, State}; use axum::middleware::{self, Next}; use axum::response::Response; use axum::routing::{get, post}; @@ -261,11 +261,18 @@ async fn egress(State(state): State) -> Json { Json(probe_egress(&state.egress)) } +/// `?display=:N`: one of the box's other screens (a Bot's own, `box-screen`). +#[derive(Debug, Default, serde::Deserialize)] +struct OnScreen { + display: Option, +} + /// The page in front of Chromium, as the box's extension last reported it (`url: null` when -/// nothing has). Read before a saved login is typed; see [`active_tab`]. -async fn active_tab_now() -> Json { - Json(active_tab::read(std::path::Path::new( - active_tab::ACTIVE_TAB_FILE, +/// nothing has), on the box's own screen or the one named. Read before a saved login is typed; +/// see [`active_tab`]. +async fn active_tab_now(Query(on): Query) -> Json { + Json(active_tab::read(&active_tab::file_for( + on.display.as_deref(), ))) } diff --git a/crates/grok-box/src/lib.rs b/crates/grok-box/src/lib.rs index 8754bda..f67132e 100644 --- a/crates/grok-box/src/lib.rs +++ b/crates/grok-box/src/lib.rs @@ -42,6 +42,9 @@ pub struct GrokBox { host_url: String, token: String, http: HttpClient, + /// The screen the computer-use and page-in-front calls go to: `None` is the box's own; + /// `:N` is a Bot's own screen (`box-screen`). See [`GrokBox::on`]. + display: Option, } impl GrokBox { @@ -65,9 +68,34 @@ impl GrokBox { host_url, token, http, + display: None, }) } + /// This client on another of the box's screens (`:N`, a Bot's own, started by + /// `box-screen up`): its clicks, keys, screenshots and page-in-front reports are that + /// screen's. Commands and files are the box's whatever the screen. + #[must_use] + pub fn on(&self, display: &str) -> Self { + Self { + display: Some(display.to_string()), + ..self.clone() + } + } + + /// `/v1/cua/` on the exec URL, on this client's screen. + fn cua(&self, path: &str) -> String { + format!("{}/v1/cua/{path}{}", self.exec_url, self.screen_query('?')) + } + + /// `display=:N` (escaped) for a screen other than the box's own; empty otherwise. + fn screen_query(&self, sep: char) -> String { + match &self.display { + Some(display) => format!("{sep}display={}", display.replace(':', "%3A")), + None => String::new(), + } + } + pub fn exec_url(&self) -> &str { &self.exec_url } @@ -219,7 +247,11 @@ impl GrokBox { pub async fn active_tab(&self) -> Result { self.send_json( "GET", - &format!("{}/v1/chrome/active-tab", self.host_url), + &format!( + "{}/v1/chrome/active-tab{}", + self.host_url, + self.screen_query('?') + ), None, true, ) @@ -278,20 +310,19 @@ impl GrokBox { } pub async fn screenshot(&self) -> Result { - self.send_json( - "POST", - &format!("{}/v1/cua/screenshot", self.exec_url), - None, - true, - ) - .await + self.send_json("POST", &self.cua("screenshot"), None, true) + .await } pub async fn screenshot_png(&self) -> Result, Error> { let (status, bytes, _) = self .send( "POST", - &format!("{}/v1/cua/screenshot?format=png", self.exec_url), + &format!( + "{}/v1/cua/screenshot?format=png{}", + self.exec_url, + self.screen_query('&') + ), None, true, "image/png", @@ -307,7 +338,7 @@ impl GrokBox { pub async fn click(&self, x: i32, y: i32, button: Option) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/click", self.exec_url), + &self.cua("click"), Some(json!({ "x": x, "y": y, "button": button })), true, ) @@ -317,7 +348,7 @@ impl GrokBox { pub async fn double_click(&self, x: i32, y: i32, button: Option) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/double-click", self.exec_url), + &self.cua("double-click"), Some(json!({ "x": x, "y": y, "button": button })), true, ) @@ -327,7 +358,7 @@ impl GrokBox { pub async fn move_pointer(&self, x: i32, y: i32) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/move", self.exec_url), + &self.cua("move"), Some(json!({ "x": x, "y": y })), true, ) @@ -344,7 +375,7 @@ impl GrokBox { ) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/drag", self.exec_url), + &self.cua("drag"), Some(json!({ "x1": x1, "y1": y1, "x2": x2, "y2": y2, "button": button })), true, ) @@ -354,7 +385,7 @@ impl GrokBox { pub async fn press(&self, x: i32, y: i32, button: Option) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/press", self.exec_url), + &self.cua("press"), Some(json!({ "x": x, "y": y, "button": button })), true, ) @@ -375,7 +406,7 @@ impl GrokBox { }); self.send_json( "POST", - &format!("{}/v1/cua/release", self.exec_url), + &self.cua("release"), Some(json!({ "x": x, "y": y, "button": button, "path": path })), true, ) @@ -385,7 +416,7 @@ impl GrokBox { pub async fn type_text(&self, text: &str) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/type", self.exec_url), + &self.cua("type"), Some(json!({ "text": text })), true, ) @@ -399,7 +430,7 @@ impl GrokBox { pub async fn key_action(&self, key: &str, action: Option<&str>) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/key", self.exec_url), + &self.cua("key"), Some(json!({ "key": key, "action": action })), true, ) @@ -409,7 +440,7 @@ impl GrokBox { pub async fn scroll(&self, x: i32, y: i32, dx: i32, dy: i32) -> Result { self.send_json( "POST", - &format!("{}/v1/cua/scroll", self.exec_url), + &self.cua("scroll"), Some(json!({ "x": x, "y": y, "dx": dx, "dy": dy })), true, ) @@ -419,13 +450,8 @@ impl GrokBox { /// Run many CUA steps in one request. The guest validates the whole plan /// before moving the pointer. pub async fn recipe(&self, request: &Value) -> Result { - self.send_json( - "POST", - &format!("{}/v1/cua/recipe", self.exec_url), - Some(request.clone()), - true, - ) - .await + self.send_json("POST", &self.cua("recipe"), Some(request.clone()), true) + .await } async fn send_json Deserialize<'de>>( @@ -674,4 +700,19 @@ mod tests { assert_eq!(box_client.exec_url(), "http://127.0.0.1:1337"); assert_eq!(box_client.host_url(), "http://127.0.0.1:1340"); } + + /// The box's own screen keeps the URLs it always had; a Bot's own screen names its display. + #[test] + fn a_client_on_another_screen_names_it_on_every_screen_call() { + let shared = + GrokBox::connect("http://127.0.0.1:1337", "http://127.0.0.1:1340", "tok").unwrap(); + assert_eq!(shared.cua("click"), "http://127.0.0.1:1337/v1/cua/click"); + let own = shared.on(":3"); + assert_eq!( + own.cua("click"), + "http://127.0.0.1:1337/v1/cua/click?display=%3A3" + ); + assert_eq!(own.screen_query('&'), "&display=%3A3"); + assert_eq!(shared.screen_query('&'), ""); + } } diff --git a/docker/Dockerfile b/docker/Dockerfile index d026aca..fc277a1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -72,6 +72,10 @@ COPY docker/chromium-policies.json /etc/opt/chrome/policies/managed/grok-box.jso # click opens a window in the running instance instead of a second browser. COPY docker/box-chromium /usr/local/bin/box-chromium COPY docker/box-chromium-pipe /usr/local/bin/box-chromium-pipe +# A Bot's own screen on a computer several Bots share, and the once-per-change clearing of a +# profile's old extension worker that the shared screen and every own screen run. +COPY docker/box-screen /usr/local/bin/box-screen +COPY docker/box-refresh-extension /usr/local/bin/box-refresh-extension # Panel with Terminal / Chromium / Files launchers, so xfce4-panel never shows # its first-run wizard and the dock is there from the first frame. COPY --chown=box:box docker/xfce/xfce4-panel.xml /home/box/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml @@ -87,6 +91,7 @@ RUN chmod 755 /usr/local/bin/entrypoint.sh /usr/local/bin/websockify-nodelay /us /usr/local/bin/box-egress-tunnel \ /usr/local/bin/box-reset-desktop /usr/local/bin/box-release-keys /usr/local/bin/box-chromium \ /usr/local/bin/box-chromium-pipe /usr/local/bin/box-active-tab \ + /usr/local/bin/box-screen /usr/local/bin/box-refresh-extension \ && chown -R box:box /home/box/.config ENV WORKSPACE_ROOT=/workspace \ diff --git a/docker/box-chromium b/docker/box-chromium index 64514c9..5ddc8ae 100644 --- a/docker/box-chromium +++ b/docker/box-chromium @@ -29,8 +29,17 @@ case "$(echo "${tunnel:-0}" | tr '[:upper:]' '[:lower:]')" in proxy_webrtc="--force-webrtc-ip-handling-policy=disable_non_proxied_udp" ;; esac +# The screen to open on: a Bot's own screen (box-screen) is passed as BOX_DISPLAY with its own +# BOX_CHROME_PROFILE; otherwise the box's own, as before. The window fills that screen. +export DISPLAY="${BOX_DISPLAY:-${DISPLAY:-:1}}" +geom="${BOX_DISPLAY_GEOM:-1280x800x24}" +win_w="${geom%%x*}" +rest="${geom#*x}" +win_h="${rest%%x*}" exec chromium \ --user-data-dir="${BOX_CHROME_PROFILE:-/home/box/chrome-profile}" \ + --window-size="${win_w},${win_h}" \ + --window-position=0,0 \ --no-first-run \ --no-default-browser-check \ --disable-dev-shm-usage \ diff --git a/docker/box-refresh-extension b/docker/box-refresh-extension new file mode 100755 index 0000000..bbc84b5 --- /dev/null +++ b/docker/box-refresh-extension @@ -0,0 +1,23 @@ +#!/bin/bash +# The box's extension runs as a service worker, and Chromium goes on running the worker script a +# profile registered first: a profile kept from an image with an older extension ran the old +# background script, and the page's focus was never reported (10 Oct 2026). A new extension +# version did not change that. So when the extension's files differ from the ones this profile +# (BOX_CHROME_PROFILE) last started with, its service workers are cleared once, before its +# Chromium starts, and every worker (the extension's, and any a site had) registers anew. +# +# Run only while no Chromium has this profile open: the entrypoint before the shared screen's, +# box-screen before a Bot's own. +set -euo pipefail +profile="${BOX_CHROME_PROFILE:-${HOME:-/home/box}/chrome-profile}" +[[ -n "${profile}" && -d "${profile}" ]] || exit 0 +stamp="${profile}/.box-active-tab.sha256" +now="$(cat /opt/box/active-tab/* | sha256sum | cut -d' ' -f1)" +if [[ -f "${stamp}" && "$(cat "${stamp}")" == "${now}" ]]; then + exit 0 +fi +if [[ -d "${profile}/Default/Service Worker" ]]; then + echo "the box extension changed; clearing the profile's service workers once" + rm -rf -- "${profile}/Default/Service Worker" +fi +printf '%s\n' "${now}" >"${stamp}" diff --git a/docker/box-screen b/docker/box-screen new file mode 100755 index 0000000..e982cd1 --- /dev/null +++ b/docker/box-screen @@ -0,0 +1,141 @@ +#!/bin/bash +# A Bot's own screen on a computer several Bots share. +# +# The box's own screen (BOX_DISPLAY, :1) is the one every Bot shares by default. A Bot told to +# use its own screen gets a slot here: its own X display, window manager and VNC server, and a +# Chromium of its own, with its own profile (so what it signs in to stays its own) that the +# server starts on that display when the Bot opens a page (`box-chromium` with this screen's +# BOX_DISPLAY and BOX_CHROME_PROFILE). The screen view (websockify on BOX_NOVNC_PORT) reaches it +# by the token `s`. +# +# box-screen up start the Bot's screen (or find it running); prints its slot as JSON +# box-screen down stop it; its slot and its profile are kept for the next `up` +# box-screen list every Bot's slot, and whether its screen is running +# +# Slots are kept in the home volume, so a Bot keeps its display number and profile across +# restarts and updates; the screens themselves start again on the next `up`. +set -euo pipefail + +MAX_SCREENS="${BOX_MAX_OWN_SCREENS:-4}" +FIRST_SLOT=2 +HOME_DIR="${HOME:-/home/box}" +SLOTS_DIR="${HOME_DIR}/.box-screens" +TOKENS_DIR="${BOX_VNC_TOKENS:-/tmp/box-vnc-tokens}" +PROFILES_DIR="${BOX_CHROME_PROFILE:-${HOME_DIR}/chrome-profile}/bots" +GEOM="${BOX_DISPLAY_GEOM:-1280x800x24}" +PASSFILE="${HOME_DIR}/.vnc/passwd" + +die() { + printf '{"error":"%s"}\n' "$1" + exit 1 +} + +bot="${2:-}" +case "${1:-}" in + up | down) + # A Bot id names a directory and a profile: letters, digits, `_` and `-` only. + [[ "${bot}" =~ ^[A-Za-z0-9_-]{1,80}$ ]] || die "a Bot id is letters, digits, _ and - only" + ;; + list) ;; + *) die "usage: box-screen up|down | list" ;; +esac + +mkdir -p "${SLOTS_DIR}" "${TOKENS_DIR}" "${PROFILES_DIR}" +# One box-screen at a time. What it starts must not inherit the lock (`9>&-` below): an X server +# holding it would make every later box-screen wait for good. +exec 9>"/tmp/box-screen.lock" +flock 9 + +running() { + [[ -S "/tmp/.X11-unix/X$1" ]] && pgrep -f "^Xvfb :$1 " >/dev/null +} + +slot_of() { + [[ -f "${SLOTS_DIR}/$1" ]] && cat "${SLOTS_DIR}/$1" +} + +allocate() { + local taken n + taken=" $(cat "${SLOTS_DIR}"/* 2>/dev/null | tr '\n' ' ') " + for ((n = FIRST_SLOT; n < FIRST_SLOT + MAX_SCREENS; n++)); do + if [[ "${taken}" != *" ${n} "* ]]; then + echo "${n}" >"${SLOTS_DIR}/$1" + echo "${n}" + return + fi + done + return 1 +} + +if [[ "$1" == "list" ]]; then + printf '{"screens":[' + sep="" + for f in "${SLOTS_DIR}"/*; do + [[ -f "${f}" ]] || continue + n="$(cat "${f}")" + up=false + running "${n}" && up=true + printf '%s{"bot":"%s","slot":%s,"display":":%s","running":%s}' \ + "${sep}" "$(basename "${f}")" "${n}" "${n}" "${up}" + sep="," + done + printf ']}\n' + exit 0 +fi + +profile="${PROFILES_DIR}/${bot}" + +if [[ "$1" == "down" ]]; then + n="$(slot_of "${bot}")" || { printf '{"bot":"%s","running":false}\n' "${bot}"; exit 0; } + pkill -f -- "--user-data-dir=${profile}( |$)" 2>/dev/null || true + pkill -f "^x11vnc -display :${n} " 2>/dev/null || true + pkill -f "^xfwm4 --display=:${n}( |$)" 2>/dev/null || true + pkill -f "^Xvfb :${n} " 2>/dev/null || true + rm -f "${TOKENS_DIR}/s${n}" + printf '{"bot":"%s","slot":%s,"display":":%s","running":false}\n' "${bot}" "${n}" "${n}" + exit 0 +fi + +# up +n="$(slot_of "${bot}")" || n="$(allocate "${bot}")" || + die "this computer already runs ${MAX_SCREENS} Bots' own screens; switch one back to the shared screen first" +mkdir -p "${profile}" + +if ! running "${n}"; then + # A screen stopped by a restart leaves its lock and socket in /tmp; nothing else starts an X + # server on this number, so both are stale here. + rm -f "/tmp/.X${n}-lock" "/tmp/.X11-unix/X${n}" + setsid -f Xvfb ":${n}" -screen 0 "${GEOM}" -ac +extension GLX +extension XTEST \ + +extension DAMAGE +render -noreset >"/tmp/xvfb-${n}.log" 2>&1 9>&- + for _ in $(seq 50); do + [[ -S "/tmp/.X11-unix/X${n}" ]] && break + sleep 0.1 + done + [[ -S "/tmp/.X11-unix/X${n}" ]] || die "the screen's X server did not start" +fi + +if ! pgrep -f "^xfwm4 --display=:${n}( |$)" >/dev/null; then + DISPLAY=":${n}" setsid -f xfwm4 --display=":${n}" --compositor=off >"/tmp/xfwm4-${n}.log" 2>&1 9>&- +fi + +if ! pgrep -f "^x11vnc -display :${n} " >/dev/null; then + [[ -r "${PASSFILE}" ]] || die "this computer has no screen password" + # DISPLAY is this screen's, so box-release-keys (run as a viewer comes and goes) releases the + # keys on this screen and not the shared one. + DISPLAY=":${n}" setsid -f x11vnc -display ":${n}" -rfbport "$((5900 + n))" -localhost \ + -rfbauth "${PASSFILE}" -forever -shared -xdamage -nowireframe -speeds lan -wait 10 \ + -defer 5 -xkb -clear_mods -clear_keys \ + -afteraccept /usr/local/bin/box-release-keys -gone /usr/local/bin/box-release-keys \ + >"/tmp/x11vnc-${n}.log" 2>&1 9>&- +fi + +printf 's%s: 127.0.0.1:%s\n' "${n}" "$((5900 + n))" >"${TOKENS_DIR}/s${n}" + +# A profile kept from an older extension runs its old background script (see +# box-refresh-extension); this Bot's Chromium is not running yet when the screen first comes up. +if ! pgrep -f -- "--user-data-dir=${profile}( |$)" >/dev/null; then + BOX_CHROME_PROFILE="${profile}" /usr/local/bin/box-refresh-extension >/dev/null +fi + +printf '{"bot":"%s","slot":%s,"display":":%s","profile":"%s","running":true}\n' \ + "${bot}" "${n}" "${n}" "${profile}" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4459bfb..9bad918 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -39,6 +39,9 @@ unset BOX_TOKEN BOX_HOST_TOKEN BOX_VNC_PASSWORD || true export WORKSPACE_ROOT="${WORKSPACE_ROOT:-/workspace}" export BOX_DESKTOP="${BOX_DESKTOP:-1}" export BOX_DISPLAY="${BOX_DISPLAY:-:1}" +# The shared screen, under a name a Bot's own screen never sets (it passes its own BOX_DISPLAY): +# box-active-tab and box-host tell the shared screen's report from a Bot's by it. +export BOX_SHARED_DISPLAY="${BOX_DISPLAY}" export BOX_DISPLAY_GEOM="${BOX_DISPLAY_GEOM:-1280x800x24}" export BOX_VNC_BIND="${BOX_VNC_BIND:-127.0.0.1:5900}" export BOX_NOVNC_PORT="${BOX_NOVNC_PORT:-6080}" @@ -203,31 +206,18 @@ start_desktop() { # 0.0.0.0 *inside* the container so Docker port-map to the veth IP works. # Host publish is 127.0.0.1:6080. 6080 is not Bearer-authenticated. echo "starting noVNC/websockify on 0.0.0.0:${BOX_NOVNC_PORT} (host publish should be loopback)" - websockify-nodelay --web="${BOX_NOVNC_WEB}" "0.0.0.0:${BOX_NOVNC_PORT}" "${BOX_VNC_BIND}" \ + # One viewer port for every screen of the box: a Bot's own screen (box-screen) is reached by + # the token s in the websocket's query, and a connection with no token is the shared screen, + # as it always was. + export BOX_VNC_TOKENS="${BOX_VNC_TOKENS:-/tmp/box-vnc-tokens}" + mkdir -p "${BOX_VNC_TOKENS}" + printf 's%s: %s\n' "${dnum}" "${BOX_VNC_BIND}" >"${BOX_VNC_TOKENS}/s${dnum}" + BOX_VNC_SHARED="${BOX_VNC_BIND}" websockify-nodelay --web="${BOX_NOVNC_WEB}" \ + --token-plugin TokenFile --token-source "${BOX_VNC_TOKENS}" "0.0.0.0:${BOX_NOVNC_PORT}" \ >/tmp/websockify.log 2>&1 & record $! } -# The box's extension runs as a service worker, and Chromium goes on running the worker script -# a profile registered first: a profile kept from an image with an older extension ran the old -# background script, and the page's focus was never reported (10 Oct 2026). A new extension -# version did not change that. So when the extension's files differ from the ones this profile -# last started with, its service workers are cleared once, before Chromium starts, and every -# worker (the extension's, and any a site had) registers anew. -refresh_extension_worker() { - local stamp="${BOX_CHROME_PROFILE}/.box-active-tab.sha256" - local now - now="$(cat /opt/box/active-tab/* | sha256sum | cut -d' ' -f1)" - if [[ -f "${stamp}" && "$(cat "${stamp}")" == "${now}" ]]; then - return - fi - if [[ -d "${BOX_CHROME_PROFILE}/Default/Service Worker" ]]; then - echo "the box extension changed; clearing the profile's service workers once" - rm -rf -- "${BOX_CHROME_PROFILE}/Default/Service Worker" - fi - printf '%s\n' "${now}" >"${stamp}" -} - start_chrome() { local bin="" candidate w h for candidate in chromium chromium-browser google-chrome google-chrome-stable; do @@ -247,7 +237,8 @@ start_chrome() { export BOX_CHROME_PROFILE="/tmp/box-chrome-profile" mkdir -p "${BOX_CHROME_PROFILE}" fi - refresh_extension_worker + # See box-refresh-extension: a profile kept from an older extension runs its old worker. + /usr/local/bin/box-refresh-extension read -r w h < <(geom_wh) export DISPLAY="${BOX_DISPLAY}" diff --git a/docker/websockify-nodelay.py b/docker/websockify-nodelay.py index 6131fc8..667ac69 100644 --- a/docker/websockify-nodelay.py +++ b/docker/websockify-nodelay.py @@ -32,6 +32,33 @@ def accept(self): socket.socket = _NoDelaySocket # type: ignore[misc] + +def _shared_screen_without_a_token() -> None: + """A connection with no token is the box's shared screen (BOX_VNC_SHARED, host:port), as it + was before a Bot could have its own screen (box-screen, token s): a server that does not + know about own screens keeps reaching the shared one.""" + import os + from urllib.parse import parse_qs, urlparse + + from websockify.websocketproxy import ProxyRequestHandler + + shared = os.environ.get("BOX_VNC_SHARED", "") + if ":" not in shared: + return + host, port = shared.rsplit(":", 1) + looked_up = ProxyRequestHandler.get_target + + def get_target(self, target_plugin): + args = parse_qs(urlparse(self.path)[4]) + if not args.get("token") and not getattr(self, "host_token", False): + return host, port + return looked_up(self, target_plugin) + + ProxyRequestHandler.get_target = get_target + + +_shared_screen_without_a_token() + if __name__ == "__main__": sys.argv[0] = "websockify" websockify_init()