Skip to main content

mobius_gateway/
computer_runtime.rs

1//! On-demand, owner-installed dependencies for the computer middleware.
2
3pub(crate) mod browser;
4pub mod config;
5pub(crate) mod desktop;
6pub(crate) mod remote_desktop;
7
8use crate::wire::ServerMessage;
9
10use std::borrow::Cow;
11use std::ffi::OsString;
12use std::fs::{self, File, OpenOptions};
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::process::Stdio;
16use std::time::Duration;
17
18use mobius::backend::model::provider::{HttpCertificate, HttpClient};
19#[cfg(unix)]
20use mobius::backend::sandbox::ProcessGroupGuard;
21use sha2::{Digest, Sha256};
22use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
23use tokio::process::Command;
24
25use crate::wire::MiddlewareConfig;
26use crate::{Error, Result};
27pub use config::{BrowserConfig, ComputerConfig, DesktopConfig, RuntimeMode};
28
29const WORKER: &str = include_str!("computer_runtime/worker.cjs");
30const DOCUMENTATION: &str = include_str!("computer_runtime/computer-control.md");
31const PACKAGE: &str = include_str!("computer_runtime/package.json");
32const LOCKFILE: &str = include_str!("computer_runtime/package-lock.json");
33const MAX_DOWNLOAD_BYTES: usize = 128 * 1024 * 1024;
34const ROOT_SANDBOX_ERROR: &str = "Chromium cannot run as root with its sandbox enabled; run the gateway as a non-root user or explicitly set computer.browser.sandbox = false.";
35
36pub(crate) enum AppUpdate {
37    Desktop(Option<ServerMessage>),
38    Browser(Option<ServerMessage>),
39}
40
41pub(crate) async fn next_app_update(
42    desktop: &mut Option<desktop::DesktopConnection>,
43    browser: &mut Option<browser::BrowserConnection>,
44) -> AppUpdate {
45    tokio::select! {
46        message = desktop::next_update(desktop) => AppUpdate::Desktop(message),
47        message = browser::next_update(browser) => AppUpdate::Browser(message),
48    }
49}
50
51pub(crate) async fn write_app_update(
52    update: AppUpdate,
53    desktop: &mut Option<desktop::DesktopConnection>,
54    browser: &mut Option<browser::BrowserConnection>,
55    writer: &mut (impl tokio::io::AsyncWrite + Unpin),
56) -> Result<()> {
57    match update {
58        AppUpdate::Desktop(message) => desktop::write_update(desktop, message, writer).await,
59        AppUpdate::Browser(message) => browser::write_update(browser, message, writer).await,
60    }
61}
62
63pub(crate) async fn prepare(
64    state_dir: &Path,
65    settings: &MiddlewareConfig,
66    config: &ComputerConfig,
67) -> Result<Option<PathBuf>> {
68    config.validate()?;
69    remote_desktop::prepare_configured_profile(config)?;
70    if !settings.enabled("computer_control") {
71        return Ok(None);
72    }
73    prepare_desktop(state_dir, config).await.map(Some)
74}
75
76pub(crate) async fn prepare_desktop(state_dir: &Path, config: &ComputerConfig) -> Result<PathBuf> {
77    config.validate()?;
78    remote_desktop::prepare_configured_profile(config)?;
79    let override_path = config.directory.as_deref().map(Cow::Borrowed).or_else(|| {
80        std::env::var_os("MOBIUS_COMPUTER_RUNTIME").map(|path| Cow::Owned(PathBuf::from(path)))
81    });
82    let path = override_path.as_deref().map_or_else(
83        || Cow::Owned(managed_directory_for(state_dir, config)),
84        Cow::Borrowed,
85    );
86    if config.mode != RuntimeMode::Managed
87        || (config.directory.is_none() && override_path.is_some())
88    {
89        validate(&path, config)?;
90        return Ok(fs::canonicalize(path)?);
91    }
92    install(&path, config).await.map_err(|error| {
93        Error::Config(format!(
94            "computer control setup failed: {error}; check the operator computer configuration"
95        ))
96    })?;
97    Ok(fs::canonicalize(path)?)
98}
99
100#[cfg(test)]
101pub(crate) fn managed_directory(state_dir: &Path) -> PathBuf {
102    managed_directory_for(state_dir, &ComputerConfig::default())
103}
104
105fn managed_directory_for(state_dir: &Path, config: &ComputerConfig) -> PathBuf {
106    // Like extensions, executable resources must remain outside sandbox-masked state.
107    let mut name = state_dir
108        .file_name()
109        .map_or_else(|| OsString::from("mobius"), OsString::from);
110    name.push("-runtimes");
111    let mut hash = Sha256::new();
112    for value in [
113        config::DEFAULTS_TEXT,
114        PACKAGE,
115        LOCKFILE,
116        WORKER,
117        DOCUMENTATION,
118    ] {
119        hash.update(value.as_bytes());
120    }
121    // Different dependency layouts must never reuse an install missing bundled Node/Chromium.
122    for path in [
123        &config.node_executable,
124        &config.npm_executable,
125        &config.playwright_module,
126        &config.browsers_directory,
127        &config.browser.executable,
128    ] {
129        hash.update([u8::from(path.is_some())]);
130        if let Some(path) = path {
131            hash.update(path.as_os_str().as_encoded_bytes());
132        }
133        hash.update([0]);
134    }
135    let revision = format!("{:x}", hash.finalize());
136    state_dir
137        .with_file_name(name)
138        .join("computer-control")
139        .join(revision)
140}
141
142async fn install(destination: &Path, config: &ComputerConfig) -> Result<()> {
143    tokio::time::timeout(
144        Duration::from_secs(config.install_timeout_seconds),
145        install_locked(destination, config),
146    )
147    .await
148    .map_err(|_| Error::Config("runtime installation timed out".into()))?
149}
150
151async fn install_locked(destination: &Path, config: &ComputerConfig) -> Result<()> {
152    if destination.exists() {
153        return validate(destination, config);
154    }
155    let parent = destination
156        .parent()
157        .ok_or_else(|| Error::Config("runtime directory has no parent".into()))?;
158    fs::create_dir_all(parent)?;
159    let lock_path = parent.join("install.lock");
160    let _lock = {
161        let file = OpenOptions::new()
162            .create(true)
163            .truncate(false)
164            .read(true)
165            .write(true)
166            .open(lock_path)?;
167        loop {
168            match file.try_lock() {
169                Ok(()) => break file,
170                Err(std::fs::TryLockError::WouldBlock) => {
171                    tokio::time::sleep(Duration::from_millis(50)).await
172                }
173                Err(std::fs::TryLockError::Error(error)) => return Err(error.into()),
174            }
175        }
176    };
177    if destination.exists() {
178        return validate(destination, config);
179    }
180    let stage = tempfile::Builder::new()
181        .prefix(".install-")
182        .tempdir_in(parent)?;
183    populate(stage.path(), config).await?;
184    validate(stage.path(), config)?;
185    // Publish only a complete runtime. Interrupted attempts leave no usable partial install.
186    fs::rename(stage.path(), destination)?;
187    Ok(())
188}
189
190fn validate(path: &Path, config: &ComputerConfig) -> Result<()> {
191    if !path.is_absolute() {
192        return Err(Error::Config(
193            "computer runtime directory must be absolute".into(),
194        ));
195    }
196    if config.node_executable.is_none() && !path.join("node").is_file() {
197        return Err(Error::Config(format!(
198            "computer runtime is missing node in {}",
199            path.display()
200        )));
201    }
202    node_executable(path, config)?;
203    for file in ["worker.cjs", "computer-control.md"] {
204        if !path.join(file).is_file() {
205            return Err(Error::Config(format!(
206                "computer runtime is missing {file} in {}",
207                path.display()
208            )));
209        }
210    }
211    let mut worker = Vec::new();
212    File::open(path.join("worker.cjs"))?
213        .take(128 * 1024)
214        .read_to_end(&mut worker)?;
215    if worker != WORKER.as_bytes() {
216        return Err(Error::Config(
217            "computer worker does not match this gateway; export its current computer resources"
218                .into(),
219        ));
220    }
221    if !playwright_module(path, config)
222        .join("package.json")
223        .is_file()
224    {
225        return Err(Error::Config(
226            "computer runtime is missing Playwright".into(),
227        ));
228    }
229    if let Some(executable) = &config.browser.executable {
230        config::resolve_executable(executable)?;
231    } else if !browsers_directory(path, config).is_dir() {
232        return Err(Error::Config(
233            "computer runtime is missing its browser installation".into(),
234        ));
235    }
236    Ok(())
237}
238
239async fn install_node(path: &Path, config: &ComputerConfig) -> Result<()> {
240    if let Some(node) = &config.node_executable {
241        config::resolve_executable(node)?;
242        return Ok(());
243    }
244    let (platform, checksum) =
245        config::node_distribution(std::env::consts::OS, std::env::consts::ARCH)?;
246    let node_version = config::node_version();
247    let archive = path.join("node.tar.gz");
248    download(
249        &format!(
250            "{}/v{node_version}/node-v{node_version}-{platform}.tar.gz",
251            config.node_download_base_url.trim_end_matches('/')
252        ),
253        &archive,
254        checksum,
255        config,
256    )
257    .await?;
258    let node_directory = path.join("distribution");
259    fs::create_dir(&node_directory)?;
260    let mut extract = Command::new(config::resolve_executable(Path::new(
261        config.tar_executable.as_ref(),
262    ))?);
263    extract
264        .arg("-xzf")
265        .arg(&archive)
266        .arg("-C")
267        .arg(&node_directory)
268        .args(["--strip-components", "1"]);
269    run(extract, path, config).await?;
270    fs::remove_file(archive)?;
271    fs::hard_link(node_directory.join("bin/node"), path.join("node"))?;
272    Ok(())
273}
274
275/// Export the gateway-owned worker, documentation, and pinned package manifests.
276/// Operators can install Playwright themselves and select system Node/Chromium.
277///
278/// # Errors
279/// Returns an error if the destination is not absolute or a resource cannot be written.
280pub fn export_resources(path: &Path) -> Result<()> {
281    if !path.is_absolute() {
282        return Err(Error::Config(
283            "computer resource destination must be absolute".into(),
284        ));
285    }
286    fs::create_dir_all(path)?;
287    for (name, content) in [
288        ("worker.cjs", WORKER),
289        ("computer-control.md", DOCUMENTATION),
290        ("package.json", PACKAGE),
291        ("package-lock.json", LOCKFILE),
292        ("LICENSE", include_str!("../LICENSE")),
293        ("NOTICE", include_str!("../NOTICE")),
294    ] {
295        fs::write(path.join(name), content)?;
296    }
297    let config = ComputerConfig::default();
298    let mut options = worker_options(path, &config)?;
299    options.playwright_module = Cow::Borrowed(Path::new("playwright"));
300    options.browsers_directory = Cow::Borrowed(Path::new("browsers"));
301    fs::write(
302        path.join("browser.json"),
303        serde_json::to_vec_pretty(&options)?,
304    )?;
305    Ok(())
306}
307
308async fn populate(path: &Path, config: &ComputerConfig) -> Result<()> {
309    config
310        .browser
311        .validate_root_sandbox(nix::unistd::geteuid().is_root())?;
312    install_node(path, config).await?;
313    export_resources(path)?;
314    if config.playwright_module.is_none() {
315        let mut npm = npm_command(path, config)?;
316        npm.args(["ci", "--ignore-scripts", "--no-fund", "--no-audit"]);
317        run(npm, path, config).await?;
318    }
319    if config.browser.executable.is_none() {
320        let mut browser = Command::new(node_executable(path, config)?);
321        browser
322            .arg(playwright_module(path, config).join("cli.js"))
323            .args(["install", "chromium"]);
324        run(browser, path, config).await?;
325    }
326    let mut verify = Command::new(node_executable(path, config)?);
327    verify.args(["-e", "(async()=>{const o=JSON.parse(process.argv[1]); const {chromium}=require(o.playwright_module); const b=await chromium.launch({headless:true,chromiumSandbox:o.sandbox,args:o.arguments,...(o.executable?{executablePath:o.executable}:{})}); await b.close()})().catch(e=>{console.error(e.message);process.exitCode=1})"])
328        .arg(serde_json::to_string(&worker_options(path, config)?)?);
329    run(verify, path, config).await?;
330    fs::remove_dir_all(path.join(".home"))?;
331    fs::remove_dir_all(path.join(".tmp"))?;
332    fs::remove_file(path.join("install.log"))?;
333    Ok(())
334}
335
336fn npm_command(path: &Path, config: &ComputerConfig) -> Result<Command> {
337    if config.node_executable.is_some() || config.npm_executable.is_some() {
338        let npm = config
339            .npm_executable
340            .as_deref()
341            .unwrap_or_else(|| Path::new("npm"));
342        return Ok(Command::new(config::resolve_executable(npm)?));
343    }
344    let mut command = Command::new(node_executable(path, config)?);
345    command.arg(path.join("distribution/lib/node_modules/npm/bin/npm-cli.js"));
346    Ok(command)
347}
348
349pub(crate) fn node_executable(path: &Path, config: &ComputerConfig) -> Result<PathBuf> {
350    match config.node_executable.as_deref() {
351        Some(executable) => config::resolve_executable(executable),
352        None => config::resolve_executable(&path.join("node")),
353    }
354}
355
356fn playwright_module<'a>(path: &Path, config: &'a ComputerConfig) -> Cow<'a, Path> {
357    config.playwright_module.as_deref().map_or_else(
358        || Cow::Owned(path.join("node_modules/playwright")),
359        Cow::Borrowed,
360    )
361}
362
363pub(crate) fn browsers_directory<'a>(path: &Path, config: &'a ComputerConfig) -> Cow<'a, Path> {
364    config
365        .browsers_directory
366        .as_deref()
367        .map_or_else(|| Cow::Owned(path.join("browsers")), Cow::Borrowed)
368}
369
370#[derive(serde::Serialize)]
371struct WorkerOptions<'a> {
372    executable: Option<PathBuf>,
373    sandbox: bool,
374    arguments: Vec<&'a str>,
375    viewport: [u32; 2],
376    image_presentation: mobius::backend::session_files::ImagePresentation,
377    start_page: &'a str,
378    playwright_module: Cow<'a, Path>,
379    browsers_directory: Cow<'a, Path>,
380    root_sandbox_error: &'static str,
381}
382
383fn worker_options<'a>(path: &Path, config: &'a ComputerConfig) -> Result<WorkerOptions<'a>> {
384    Ok(WorkerOptions {
385        executable: config
386            .browser
387            .executable
388            .as_deref()
389            .map(config::resolve_executable)
390            .transpose()?,
391        sandbox: config.browser.sandbox,
392        arguments: config.browser.launch_arguments().collect(),
393        viewport: config.browser.viewport,
394        image_presentation: mobius::backend::session_files::ImagePresentation::default(),
395        start_page: &config.browser.start_page,
396        playwright_module: playwright_module(path, config),
397        browsers_directory: browsers_directory(path, config),
398        root_sandbox_error: ROOT_SANDBOX_ERROR,
399    })
400}
401
402pub(crate) fn worker_command(
403    path: &Path,
404    config: &ComputerConfig,
405) -> Result<mobius::backend::sandbox::WorkerCommand> {
406    config.validate()?;
407    Ok(mobius::backend::sandbox::WorkerCommand {
408        executable: node_executable(path, config)?,
409        arguments: vec![
410            path.join("worker.cjs").to_string_lossy().into_owned(),
411            serde_json::to_string(&worker_options(path, config)?)?,
412        ],
413    })
414}
415
416pub(crate) fn resource_roots(
417    path: &Path,
418    config: &ComputerConfig,
419    state_dir: &Path,
420    workspace: &Path,
421    attached_folders: &[PathBuf],
422) -> Result<Vec<PathBuf>> {
423    let forbidden = std::iter::once(state_dir)
424        .chain(std::iter::once(workspace))
425        .chain(attached_folders.iter().map(PathBuf::as_path))
426        .map(fs::canonicalize)
427        .collect::<std::io::Result<Vec<_>>>()?;
428    let node = node_executable(path, config)?;
429    let browser = config
430        .browser
431        .executable
432        .as_deref()
433        .map(config::resolve_executable)
434        .transpose()?;
435    let playwright = playwright_module(path, config);
436    let browsers = browsers_directory(path, config);
437    let mut roots = [
438        Some(path),
439        Some(playwright.as_ref()),
440        Some(browsers.as_ref()),
441        node.parent(),
442        browser.as_deref().and_then(Path::parent),
443    ]
444    .into_iter()
445    .flatten()
446    .filter(|path| path.exists())
447    .map(fs::canonicalize)
448    .collect::<std::io::Result<Vec<_>>>()?;
449    for root in &roots {
450        if !root.is_dir() {
451            return Err(Error::Config(
452                "computer resource roots must be directories".into(),
453            ));
454        }
455        if forbidden
456            .iter()
457            .any(|path| root.starts_with(path) || path.starts_with(root))
458        {
459            return Err(Error::Config(
460                "computer resources must remain outside gateway state and all writable workspaces"
461                    .into(),
462            ));
463        }
464    }
465    roots.sort_unstable();
466    roots.dedup();
467    Ok(roots)
468}
469
470async fn download(
471    url: &str,
472    destination: &Path,
473    checksum: &str,
474    config: &ComputerConfig,
475) -> Result<()> {
476    let mut client = HttpClient::builder()
477        .https_only(true)
478        .connect_timeout(Duration::from_secs(config.download_connect_timeout_seconds))
479        .timeout(Duration::from_secs(config.download_timeout_seconds));
480    let ca_file =
481        config.ca_file.as_deref().map(Cow::Borrowed).or_else(|| {
482            std::env::var_os("SSL_CERT_FILE").map(|path| Cow::Owned(PathBuf::from(path)))
483        });
484    if let Some(path) = ca_file {
485        for certificate in ca_certificates(&path).await? {
486            client = client.add_root_certificate(certificate);
487        }
488    }
489    let client = client
490        .build()
491        .map_err(|error| Error::Config(error.to_string()))?;
492    let mut response = client
493        .get(url)
494        .send()
495        .await
496        .and_then(|response| response.error_for_status())
497        .map_err(|error| Error::Config(error.to_string()))?;
498    let mut file = tokio::fs::File::create(destination).await?;
499    let mut hash = Sha256::new();
500    let mut size = 0_usize;
501    while let Some(chunk) = response
502        .chunk()
503        .await
504        .map_err(|error| Error::Config(error.to_string()))?
505    {
506        size = size.saturating_add(chunk.len());
507        if size > MAX_DOWNLOAD_BYTES {
508            return Err(Error::Config(
509                "runtime download exceeds its size limit".into(),
510            ));
511        }
512        hash.update(&chunk);
513        file.write_all(&chunk).await?;
514    }
515    if format!("{:x}", hash.finalize()) != checksum {
516        return Err(Error::Config("Node runtime checksum did not match".into()));
517    }
518    file.flush().await?;
519    Ok(())
520}
521
522async fn run(mut command: Command, path: &Path, config: &ComputerConfig) -> Result<()> {
523    fs::create_dir_all(path.join(".home"))?;
524    fs::create_dir_all(path.join(".tmp"))?;
525    command
526        .current_dir(path)
527        .kill_on_drop(true)
528        .env_clear()
529        .env("HOME", path.join(".home"))
530        .env("TMPDIR", path.join(".tmp"))
531        .env("PATH", installer_path(path, config)?)
532        .env(
533            "PLAYWRIGHT_BROWSERS_PATH",
534            browsers_directory(path, config).as_ref(),
535        )
536        .env("PLAYWRIGHT_SKIP_BROWSER_GC", "1")
537        .env("npm_config_userconfig", "/dev/null")
538        .env("npm_config_globalconfig", path.join(".home/global-npmrc"))
539        .stdin(Stdio::null())
540        .stdout(Stdio::null())
541        .stderr(File::create(path.join("install.log"))?);
542    forward_installer_environment(&mut command, |name| std::env::var_os(name));
543    if let Some(path) = &config.ca_file {
544        command
545            .env("SSL_CERT_FILE", path)
546            .env("NODE_EXTRA_CA_CERTS", path)
547            .env("npm_config_cafile", path);
548    }
549    #[cfg(unix)]
550    command.process_group(0);
551    let mut child = command.spawn()?;
552    #[cfg(unix)]
553    let _group = ProcessGroupGuard::new(&child)?;
554    if !child.wait().await?.success() {
555        // npm/download errors can contain authenticated proxy URLs. Keep them
556        // inside the private installation directory rather than returning them to a client.
557        return Err(Error::Config("runtime installer failed".into()));
558    }
559    Ok(())
560}
561
562fn installer_path(path: &Path, config: &ComputerConfig) -> Result<OsString> {
563    let directory = match &config.node_executable {
564        Some(node) => config::resolve_executable(node)?
565            .parent()
566            .ok_or_else(|| Error::Config("Node executable has no parent".into()))?
567            .to_path_buf(),
568        None => path.join("distribution/bin"),
569    };
570    // npm/Playwright subprocesses inherit the selected Node directory and
571    // standard system tools, never the gateway's ambient executable search path.
572    std::env::join_paths([directory, PathBuf::from("/usr/bin"), PathBuf::from("/bin")])
573        .map_err(|error| Error::Config(error.to_string()))
574}
575
576fn forward_installer_environment(command: &mut Command, get: impl Fn(&str) -> Option<OsString>) {
577    crate::process_environment::forward_network_environment(command, &get);
578    for name in [
579        "NODE_EXTRA_CA_CERTS",
580        "NODE_USE_SYSTEM_CA",
581        "npm_config_cafile",
582        "NPM_CONFIG_CAFILE",
583        "PLAYWRIGHT_DOWNLOAD_HOST",
584        "PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST",
585    ] {
586        if let Some(value) = get(name) {
587            command.env(name, value);
588        }
589    }
590    // Node/npm use their own CA setting rather than OpenSSL's SSL_CERT_FILE.
591    if get("NODE_EXTRA_CA_CERTS").is_none()
592        && let Some(value) = get("SSL_CERT_FILE")
593    {
594        command.env("NODE_EXTRA_CA_CERTS", value);
595    }
596}
597
598async fn ca_certificates(path: &Path) -> Result<Vec<HttpCertificate>> {
599    const MAX_CA_BYTES: usize = 1024 * 1024;
600    let mut bytes = Vec::new();
601    tokio::fs::File::open(path)
602        .await?
603        .take(
604            u64::try_from(MAX_CA_BYTES.saturating_add(1))
605                .map_err(|_| Error::Config("computer CA limit is not representable".into()))?,
606        )
607        .read_to_end(&mut bytes)
608        .await?;
609    if bytes.len() > MAX_CA_BYTES {
610        return Err(Error::Config(
611            "computer CA bundle exceeds its size limit".into(),
612        ));
613    }
614    let certificates = HttpCertificate::from_pem_bundle(&bytes)
615        .map_err(|_| Error::Config("invalid computer CA certificate bundle".into()))?;
616    if certificates.is_empty() {
617        return Err(Error::Config(
618            "computer CA bundle contains no certificates".into(),
619        ));
620    }
621    Ok(certificates)
622}
623
624#[cfg(test)]
625mod tests;