Skip to main content

mobius_gateway/computer_runtime/
config.rs

1//! Operator-owned computer runtime and desktop configuration.
2
3use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7
8use serde::{Deserialize, Serialize};
9
10use crate::{Error, Result};
11
12pub(super) const DEFAULTS_TEXT: &str = include_str!("defaults.toml");
13
14// Required DTOs never call public Default implementations while parsing the
15// embedded source. Missing fields therefore fail instead of recursing into LazyLock.
16static DEFAULTS: LazyLock<ComputerDefaults> = LazyLock::new(|| {
17    let defaults: ComputerDefaults = mobius::config::embedded(DEFAULTS_TEXT);
18    for application in [
19        &defaults.applications.window_manager,
20        &defaults.applications.panel,
21        &defaults.applications.wallpaper,
22        &defaults.applications.terminal,
23        &defaults.applications.files,
24    ] {
25        command_name(&application.executable).expect("valid embedded desktop command");
26        for argument in &application.arguments {
27            valid_argument(argument).expect("valid embedded desktop argument");
28        }
29    }
30    defaults
31});
32
33#[derive(Deserialize)]
34#[serde(deny_unknown_fields)]
35struct ComputerDefaults {
36    mode: RuntimeMode,
37    install_timeout_seconds: u64,
38    download_connect_timeout_seconds: u64,
39    download_timeout_seconds: u64,
40    node_download_base_url: String,
41    tar_executable: String,
42    node: NodeDefaults,
43    browser: BrowserDefaults,
44    desktop: DesktopDefaults,
45    applications: ApplicationDefaults,
46    labels: LabelDefaults,
47}
48
49#[derive(Deserialize)]
50#[serde(deny_unknown_fields)]
51struct BrowserDefaults {
52    sandbox: bool,
53    arguments: Vec<String>,
54    window_size: [u32; 2],
55    window_position: [i32; 2],
56    start_page: String,
57    viewport: [u32; 2],
58}
59
60#[derive(Deserialize)]
61#[serde(deny_unknown_fields)]
62struct DesktopDefaults {
63    resolution: [u32; 2],
64    depth: u8,
65    display_start: u16,
66    display_end: u16,
67    startup_timeout_seconds: u64,
68    tools: ToolDefaults,
69}
70
71#[derive(Deserialize)]
72#[serde(deny_unknown_fields)]
73struct ToolDefaults {
74    xauth: String,
75    xvnc: Vec<String>,
76    vncconfig: Vec<String>,
77    setpriv: String,
78}
79
80#[derive(Deserialize)]
81#[serde(deny_unknown_fields)]
82struct ApplicationDefaults {
83    window_manager: ApplicationCommand,
84    panel: ApplicationCommand,
85    wallpaper: ApplicationCommand,
86    terminal: ApplicationCommand,
87    files: ApplicationCommand,
88}
89
90#[derive(Deserialize)]
91#[serde(deny_unknown_fields)]
92struct ApplicationCommand {
93    executable: String,
94    arguments: Vec<String>,
95}
96
97#[derive(Deserialize)]
98#[serde(deny_unknown_fields)]
99struct NodeDefaults {
100    version: String,
101    distributions: BTreeMap<String, NodeDistribution>,
102}
103
104#[derive(Deserialize)]
105#[serde(deny_unknown_fields)]
106struct NodeDistribution {
107    platform: String,
108    checksum: String,
109}
110
111#[derive(Deserialize)]
112#[serde(deny_unknown_fields)]
113struct LabelDefaults {
114    browser: String,
115    terminal: String,
116    files: String,
117}
118
119/// How executable computer resources are supplied by the operator.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum RuntimeMode {
123    /// Install pinned dependencies on demand into the managed directory.
124    Managed,
125    /// Use an operator-provisioned directory without downloading or changing it.
126    Preinstalled,
127}
128
129/// Computer resources and policy supplied by the gateway operator.
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(default, deny_unknown_fields)]
132pub struct ComputerConfig {
133    /// Managed installation or preinstalled resources without any downloads.
134    pub mode: RuntimeMode,
135    /// Resource directory; omitted means the gateway-derived managed directory.
136    pub directory: Option<PathBuf>,
137    /// System Node executable; omitted uses the pinned bundled Node.
138    pub node_executable: Option<PathBuf>,
139    /// npm executable for installation with a system Node.
140    pub npm_executable: Option<PathBuf>,
141    /// External Playwright module directory, containing its package.json.
142    pub playwright_module: Option<PathBuf>,
143    /// Browser installation directory; omitted uses resources/browsers.
144    pub browsers_directory: Option<PathBuf>,
145    /// Deadline for the complete managed installation.
146    pub install_timeout_seconds: u64,
147    /// Deadline for connecting to a runtime distribution server.
148    pub download_connect_timeout_seconds: u64,
149    /// Deadline for one pinned distribution download.
150    pub download_timeout_seconds: u64,
151    /// HTTPS mirror of the pinned Node distribution directory.
152    pub node_download_base_url: Cow<'static, str>,
153    /// tar command or absolute executable path used for the pinned Node archive.
154    pub tar_executable: Cow<'static, str>,
155    /// Additional PEM CA bundle for runtime downloads and npm/Playwright installation.
156    /// Omitted means SSL_CERT_FILE (and Node's own CA environment) is respected.
157    pub ca_file: Option<PathBuf>,
158    /// Browser launch policy shared by all launch paths.
159    pub browser: BrowserConfig,
160    /// Linux virtual desktop policy.
161    pub desktop: DesktopConfig,
162}
163
164impl Default for ComputerConfig {
165    fn default() -> Self {
166        Self {
167            mode: DEFAULTS.mode,
168            directory: None,
169            node_executable: None,
170            npm_executable: None,
171            playwright_module: None,
172            browsers_directory: None,
173            install_timeout_seconds: DEFAULTS.install_timeout_seconds,
174            download_connect_timeout_seconds: DEFAULTS.download_connect_timeout_seconds,
175            download_timeout_seconds: DEFAULTS.download_timeout_seconds,
176            node_download_base_url: Cow::Borrowed(&DEFAULTS.node_download_base_url),
177            tar_executable: Cow::Borrowed(&DEFAULTS.tar_executable),
178            ca_file: None,
179            browser: BrowserConfig::default(),
180            desktop: DesktopConfig::default(),
181        }
182    }
183}
184
185/// Browser settings; the gateway always owns its private CDP endpoint and profile.
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(default, deny_unknown_fields)]
188pub struct BrowserConfig {
189    /// Chromium-compatible executable path or command; omitted uses Playwright discovery.
190    pub executable: Option<PathBuf>,
191    /// Private persistent profile directory; omitted uses gateway desktop state.
192    pub profile_directory: Option<PathBuf>,
193    /// Keep Chromium's internal sandbox enabled. Disabling requires an explicit operator choice.
194    pub sandbox: bool,
195    /// Additional launch switches. Profile, CDP, and sandbox switches are rejected.
196    pub arguments: Cow<'static, [String]>,
197    /// Headed window width and height in pixels.
198    pub window_size: [u32; 2],
199    /// Headed window x and y position in pixels.
200    pub window_position: [i32; 2],
201    /// Initial page for a newly launched browser or session tab.
202    pub start_page: Cow<'static, str>,
203    /// Headless browser viewport width and height in pixels.
204    pub viewport: [u32; 2],
205}
206
207impl Default for BrowserConfig {
208    fn default() -> Self {
209        Self {
210            executable: None,
211            profile_directory: None,
212            sandbox: DEFAULTS.browser.sandbox,
213            arguments: Cow::Borrowed(&DEFAULTS.browser.arguments),
214            window_size: DEFAULTS.browser.window_size,
215            window_position: DEFAULTS.browser.window_position,
216            start_page: Cow::Borrowed(&DEFAULTS.browser.start_page),
217            viewport: DEFAULTS.browser.viewport,
218        }
219    }
220}
221
222/// Optional desktop component. Missing default applications are skipped.
223#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(default, deny_unknown_fields)]
225pub struct DesktopApplication {
226    /// Enable this component when its executable is available.
227    pub enabled: bool,
228    /// Command or absolute path; omitted uses this component's manifest default.
229    pub executable: Option<String>,
230    /// Arguments; omitted uses this component's manifest default, while [] clears them.
231    pub arguments: Option<Vec<String>>,
232}
233
234impl Default for DesktopApplication {
235    fn default() -> Self {
236        Self {
237            enabled: true,
238            executable: None,
239            arguments: None,
240        }
241    }
242}
243
244/// Executables needed for the private virtual display and launcher lifecycle.
245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct DesktopTools {
248    /// Xauthority writer command or path.
249    pub xauth: Cow<'static, str>,
250    /// Ordered private VNC server command/path candidates.
251    pub xvnc: Cow<'static, [String]>,
252    /// Ordered VNC input-control command/path candidates.
253    pub vncconfig: Cow<'static, [String]>,
254    /// Launcher parent-death helper command or path.
255    pub setpriv: Cow<'static, str>,
256}
257
258impl Default for DesktopTools {
259    fn default() -> Self {
260        let tools = &DEFAULTS.desktop.tools;
261        Self {
262            xauth: Cow::Borrowed(&tools.xauth),
263            xvnc: Cow::Borrowed(&tools.xvnc),
264            vncconfig: Cow::Borrowed(&tools.vncconfig),
265            setpriv: Cow::Borrowed(&tools.setpriv),
266        }
267    }
268}
269
270/// Operator-provided artwork and tint2 templates, with bundled defaults when omitted.
271#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(default, deny_unknown_fields)]
273pub struct DesktopBranding {
274    /// Wallpaper image.
275    pub wallpaper: Option<PathBuf>,
276    /// Status-panel logo image.
277    pub logo: Option<PathBuf>,
278    /// Browser launcher icon; omitted uses the selected browser's installation icon.
279    pub browser_icon: Option<PathBuf>,
280    /// Terminal launcher icon.
281    pub terminal_icon: Option<PathBuf>,
282    /// File-manager launcher icon.
283    pub files_icon: Option<PathBuf>,
284    /// tint2 launcher-panel template. @DIRECTORY@ resolves to private desktop state.
285    pub panel_config: Option<PathBuf>,
286    /// tint2 status-panel template. @DIRECTORY@ resolves to private desktop state.
287    pub status_config: Option<PathBuf>,
288    /// Browser launcher display name.
289    pub browser_label: Cow<'static, str>,
290    /// Terminal launcher display name.
291    pub terminal_label: Cow<'static, str>,
292    /// File-manager launcher display name.
293    pub files_label: Cow<'static, str>,
294}
295
296impl Default for DesktopBranding {
297    fn default() -> Self {
298        Self {
299            wallpaper: None,
300            logo: None,
301            browser_icon: None,
302            terminal_icon: None,
303            files_icon: None,
304            panel_config: None,
305            status_config: None,
306            browser_label: Cow::Borrowed(&DEFAULTS.labels.browser),
307            terminal_label: Cow::Borrowed(&DEFAULTS.labels.terminal),
308            files_label: Cow::Borrowed(&DEFAULTS.labels.files),
309        }
310    }
311}
312
313/// Linux desktop geometry, tool discovery, optional components, and artwork.
314#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(default, deny_unknown_fields)]
316pub struct DesktopConfig {
317    /// Private virtual display width and height in pixels.
318    pub resolution: [u32; 2],
319    /// Virtual display colour depth: 16, 24, or 32.
320    pub depth: u8,
321    /// First candidate X11 display number, inclusive.
322    pub display_start: u16,
323    /// Last candidate X11 display number, exclusive.
324    pub display_end: u16,
325    /// Display/browser startup deadline.
326    pub startup_timeout_seconds: u64,
327    /// Private display tools.
328    pub tools: DesktopTools,
329    /// Optional window manager.
330    pub window_manager: DesktopApplication,
331    /// Optional tint2-compatible panel.
332    pub panel: DesktopApplication,
333    /// Optional wallpaper setter, whose last argument is the artwork path.
334    pub wallpaper: DesktopApplication,
335    /// Optional terminal launcher.
336    pub terminal: DesktopApplication,
337    /// Optional file-manager launcher.
338    pub files: DesktopApplication,
339    /// Artwork and panel templates.
340    pub branding: DesktopBranding,
341}
342
343impl Default for DesktopConfig {
344    fn default() -> Self {
345        Self {
346            resolution: DEFAULTS.desktop.resolution,
347            depth: DEFAULTS.desktop.depth,
348            display_start: DEFAULTS.desktop.display_start,
349            display_end: DEFAULTS.desktop.display_end,
350            startup_timeout_seconds: DEFAULTS.desktop.startup_timeout_seconds,
351            tools: DesktopTools::default(),
352            window_manager: DesktopApplication::default(),
353            panel: DesktopApplication::default(),
354            wallpaper: DesktopApplication::default(),
355            terminal: DesktopApplication::default(),
356            files: DesktopApplication::default(),
357            branding: DesktopBranding::default(),
358        }
359    }
360}
361
362impl ComputerConfig {
363    /// Validate operator policy without installing or executing anything.
364    ///
365    /// # Errors
366    /// Rejects invalid directories, launch switches, URLs, dimensions, or deadlines.
367    pub fn validate(&self) -> Result<()> {
368        for path in [
369            &self.directory,
370            &self.playwright_module,
371            &self.browsers_directory,
372            &self.ca_file,
373        ]
374        .into_iter()
375        .flatten()
376        {
377            absolute_path(path)?;
378        }
379        for path in [&self.node_executable, &self.npm_executable]
380            .into_iter()
381            .flatten()
382        {
383            command_name(path.as_os_str().to_str().ok_or_else(invalid_command)?)?;
384        }
385        deadline(self.install_timeout_seconds)?;
386        deadline(self.download_connect_timeout_seconds)?;
387        deadline(self.download_timeout_seconds)?;
388        let url = url::Url::parse(&self.node_download_base_url)
389            .map_err(|_| Error::Config("invalid Node download mirror".into()))?;
390        if url.scheme() != "https"
391            || url.host_str().is_none()
392            || !url.username().is_empty()
393            || url.password().is_some()
394            || url.query().is_some()
395            || url.fragment().is_some()
396        {
397            return Err(Error::Config(
398                "Node download mirror must be HTTPS without credentials".into(),
399            ));
400        }
401        command_name(&self.tar_executable)?;
402        self.browser.validate()?;
403        self.desktop.validate()
404    }
405}
406
407impl BrowserConfig {
408    pub(crate) fn validate_root_sandbox(&self, root: bool) -> Result<()> {
409        if root && self.sandbox {
410            return Err(Error::Config(super::ROOT_SANDBOX_ERROR.into()));
411        }
412        Ok(())
413    }
414
415    pub(crate) fn validate(&self) -> Result<()> {
416        if let Some(path) = &self.executable {
417            command_name(path.as_os_str().to_str().ok_or_else(invalid_command)?)?;
418        }
419        if let Some(path) = &self.profile_directory {
420            absolute_path(path)?;
421        }
422        dimensions(self.window_size)?;
423        dimensions(self.viewport)?;
424        if self.arguments.len() > 128 {
425            return Err(Error::Config("too many browser arguments".into()));
426        }
427        valid_text(&self.start_page)?;
428        for argument in self.arguments.iter() {
429            valid_text(argument)?;
430            let switch = argument.split('=').next().unwrap_or(argument);
431            if !argument.starts_with("--")
432                || argument.to_ascii_lowercase().contains("sandbox")
433                || switch.starts_with("--remote-debugging")
434                || [
435                    "--remote-debugging-address",
436                    "--remote-debugging-port",
437                    "--remote-debugging-pipe",
438                    "--remote-allow-origins",
439                    "--user-data-dir",
440                    "--profile-directory",
441                    "--no-sandbox",
442                    "--disable-setuid-sandbox",
443                    "--disable-seccomp-filter-sandbox",
444                    "--headless",
445                    "--window-size",
446                    "--window-position",
447                ]
448                .contains(&switch)
449            {
450                return Err(Error::Config(
451                    "browser arguments cannot override private control or sandbox policy".into(),
452                ));
453            }
454        }
455        let url = url::Url::parse(&self.start_page)
456            .map_err(|_| Error::Config("invalid browser start page".into()))?;
457        if !matches!(url.scheme(), "http" | "https" | "about")
458            || (url.scheme() == "about" && self.start_page != "about:blank")
459            || !url.username().is_empty()
460            || url.password().is_some()
461        {
462            return Err(Error::Config(
463                "browser start page must be HTTP(S) or about:blank".into(),
464            ));
465        }
466        Ok(())
467    }
468
469    pub(crate) fn launch_arguments(&self) -> impl Iterator<Item = &str> {
470        self.arguments
471            .iter()
472            .map(String::as_str)
473            .chain((!self.sandbox).then_some("--no-sandbox"))
474    }
475
476    pub(crate) fn headed_arguments(&self, profile: &Path) -> Vec<Cow<'_, str>> {
477        let mut arguments = self
478            .launch_arguments()
479            .map(Cow::Borrowed)
480            .collect::<Vec<_>>();
481        arguments.extend([
482            Cow::Owned(format!(
483                "--window-size={},{}",
484                self.window_size[0], self.window_size[1]
485            )),
486            Cow::Owned(format!(
487                "--window-position={},{}",
488                self.window_position[0], self.window_position[1]
489            )),
490            "--remote-debugging-address=127.0.0.1".into(),
491            "--remote-debugging-port=0".into(),
492            Cow::Owned(format!("--user-data-dir={}", profile.display())),
493            Cow::Borrowed(&self.start_page),
494        ]);
495        arguments
496    }
497}
498
499impl DesktopConfig {
500    fn validate(&self) -> Result<()> {
501        dimensions(self.resolution)?;
502        deadline(self.startup_timeout_seconds)?;
503        if !matches!(self.depth, 16 | 24 | 32)
504            || self.display_start == 0
505            || self.display_end <= self.display_start
506            || self.display_end > 60000
507            || self.display_end - self.display_start > 256
508        {
509            return Err(Error::Config(
510                "invalid desktop depth or display range".into(),
511            ));
512        }
513        for command in [self.tools.xauth.as_ref(), self.tools.setpriv.as_ref()]
514            .into_iter()
515            .chain(self.tools.xvnc.iter().map(String::as_str))
516            .chain(self.tools.vncconfig.iter().map(String::as_str))
517        {
518            command_name(command)?;
519        }
520        if self.tools.xvnc.is_empty() || self.tools.vncconfig.is_empty() {
521            return Err(invalid_command());
522        }
523        for application in [
524            &self.window_manager,
525            &self.panel,
526            &self.wallpaper,
527            &self.terminal,
528            &self.files,
529        ] {
530            if let Some(executable) = &application.executable {
531                command_name(executable)?;
532            }
533            if let Some(arguments) = &application.arguments {
534                if arguments.len() > 128 {
535                    return Err(Error::Config("too many desktop arguments".into()));
536                }
537                for argument in arguments {
538                    valid_argument(argument)?;
539                }
540            }
541        }
542        for path in [
543            &self.branding.wallpaper,
544            &self.branding.logo,
545            &self.branding.browser_icon,
546            &self.branding.terminal_icon,
547            &self.branding.files_icon,
548            &self.branding.panel_config,
549            &self.branding.status_config,
550        ]
551        .into_iter()
552        .flatten()
553        {
554            absolute_path(path)?;
555        }
556        for label in [
557            &self.branding.browser_label,
558            &self.branding.terminal_label,
559            &self.branding.files_label,
560        ] {
561            valid_text(label)?;
562        }
563        Ok(())
564    }
565}
566
567/// Resolve an optional desktop app; missing defaults do not disable the whole desktop.
568#[cfg(any(target_os = "linux", test))]
569pub(crate) enum ApplicationRole {
570    WindowManager,
571    Panel,
572    Wallpaper,
573    Terminal,
574    Files,
575}
576
577#[cfg(any(target_os = "linux", test))]
578pub(crate) fn application(
579    config: &DesktopApplication,
580    role: ApplicationRole,
581) -> Result<Option<(PathBuf, &[String])>> {
582    if !config.enabled {
583        return Ok(None);
584    }
585    let defaults = match role {
586        ApplicationRole::WindowManager => &DEFAULTS.applications.window_manager,
587        ApplicationRole::Panel => &DEFAULTS.applications.panel,
588        ApplicationRole::Wallpaper => &DEFAULTS.applications.wallpaper,
589        ApplicationRole::Terminal => &DEFAULTS.applications.terminal,
590        ApplicationRole::Files => &DEFAULTS.applications.files,
591    };
592    let executable = config.executable.as_deref().unwrap_or(&defaults.executable);
593    let path = match resolve_executable(Path::new(executable)) {
594        Ok(path) => path,
595        Err(_) if config.executable.is_none() => return Ok(None),
596        Err(error) => return Err(error),
597    };
598    let arguments = config.arguments.as_deref().unwrap_or(&defaults.arguments);
599    Ok(Some((path, arguments)))
600}
601
602pub(crate) fn resolve_executable(command: &Path) -> Result<PathBuf> {
603    command_name(command.as_os_str().to_str().ok_or_else(invalid_command)?)?;
604    let path = if command.is_absolute() {
605        Some(Cow::Borrowed(command))
606    } else {
607        std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
608            .map(|directory| directory.join(command))
609            .find(|path| is_executable(path))
610            .map(Cow::Owned)
611    };
612    let path = path.filter(|path| is_executable(path)).ok_or_else(|| {
613        Error::Config(format!(
614            "computer runtime executable is unavailable: {}",
615            command.display()
616        ))
617    })?;
618    Ok(std::fs::canonicalize(path)?)
619}
620
621pub(crate) fn resolve_candidates(commands: &[String]) -> Result<PathBuf> {
622    for command in commands {
623        if let Ok(path) = resolve_executable(Path::new(command)) {
624            return Ok(path);
625        }
626    }
627    Err(Error::Config(format!(
628        "desktop requires {}",
629        commands.join(" or ")
630    )))
631}
632
633pub(crate) fn node_version() -> &'static str {
634    &DEFAULTS.node.version
635}
636
637pub(crate) fn node_distribution(os: &str, arch: &str) -> Result<(&'static str, &'static str)> {
638    let key = format!("{os}-{arch}");
639    let distribution =
640        DEFAULTS.node.distributions.get(&key).ok_or_else(|| {
641            Error::Config(format!("computer control has no runtime for {os}/{arch}"))
642        })?;
643    Ok((&distribution.platform, &distribution.checksum))
644}
645
646fn is_executable(path: &Path) -> bool {
647    #[cfg(unix)]
648    {
649        use std::os::unix::fs::PermissionsExt as _;
650        path.metadata()
651            .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
652    }
653    #[cfg(not(unix))]
654    {
655        path.is_file()
656    }
657}
658
659fn absolute_path(path: &Path) -> Result<()> {
660    if !path.is_absolute()
661        || path
662            .components()
663            .any(|part| matches!(part, std::path::Component::ParentDir))
664    {
665        return Err(Error::Config(
666            "computer resource paths must be absolute without parent traversal".into(),
667        ));
668    }
669    valid_text(path.to_str().ok_or_else(invalid_command)?)
670}
671
672fn command_name(command: &str) -> Result<()> {
673    valid_text(command)?;
674    let path = Path::new(command);
675    if !path.is_absolute()
676        && (path.components().count() != 1
677            || !matches!(
678                path.components().next(),
679                Some(std::path::Component::Normal(_))
680            ))
681    {
682        return Err(invalid_command());
683    }
684    Ok(())
685}
686
687fn valid_text(value: &str) -> Result<()> {
688    if value.is_empty() {
689        return Err(invalid_command());
690    }
691    valid_argument(value)
692}
693
694fn valid_argument(value: &str) -> Result<()> {
695    if value.len() > 4096 || value.chars().any(char::is_control) {
696        return Err(invalid_command());
697    }
698    Ok(())
699}
700
701fn invalid_command() -> Error {
702    Error::Config("invalid computer executable, argument, or path".into())
703}
704
705fn deadline(seconds: u64) -> Result<()> {
706    crate::config::bounded("computer deadline", seconds, 1..=3600)
707}
708
709fn dimensions([width, height]: [u32; 2]) -> Result<()> {
710    if !mobius::backend::session_files::ImagePresentation::default().fits(width, height) {
711        let limits = mobius::backend::session_files::ImagePresentation::default();
712        return Err(Error::Config(format!(
713            "computer capture dimensions must be positive and fit {} pixels on the longest side and {} patches of 32 × 32 pixels; reduce the viewport, window size or desktop resolution",
714            limits.max_dimension, limits.max_patches
715        )));
716    }
717    Ok(())
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn manifest_defaults_borrow_immutable_storage() {
726        let defaults = ComputerConfig::default();
727        assert!(matches!(defaults.node_download_base_url, Cow::Borrowed(_)));
728        assert!(matches!(defaults.tar_executable, Cow::Borrowed(_)));
729        assert!(matches!(defaults.browser.arguments, Cow::Borrowed(_)));
730        assert!(matches!(defaults.browser.start_page, Cow::Borrowed(_)));
731        assert!(matches!(defaults.desktop.tools.xauth, Cow::Borrowed(_)));
732        assert!(matches!(defaults.desktop.tools.xvnc, Cow::Borrowed(_)));
733        assert!(matches!(defaults.desktop.tools.vncconfig, Cow::Borrowed(_)));
734        assert!(matches!(defaults.desktop.tools.setpriv, Cow::Borrowed(_)));
735        assert!(matches!(
736            defaults.desktop.branding.browser_label,
737            Cow::Borrowed(_)
738        ));
739        assert!(matches!(
740            defaults.desktop.branding.terminal_label,
741            Cow::Borrowed(_)
742        ));
743        assert!(matches!(
744            defaults.desktop.branding.files_label,
745            Cow::Borrowed(_)
746        ));
747    }
748
749    #[test]
750    fn mutating_a_borrowed_default_preserves_other_operator_configurations() {
751        let mut defaults = ComputerConfig::default();
752        defaults
753            .browser
754            .arguments
755            .to_mut()
756            .push("--mute-audio".into());
757        assert!(matches!(defaults.browser.arguments, Cow::Owned(_)));
758        assert!(
759            !ComputerConfig::default()
760                .browser
761                .arguments
762                .iter()
763                .any(|argument| argument == "--mute-audio")
764        );
765    }
766
767    #[test]
768    fn configured_values_own_their_storage_and_remain_editable() {
769        let mut operator: ComputerConfig = toml::from_str(
770            "[browser]\narguments = ['--mute-audio']\nstart_page = 'https://example.invalid'",
771        )
772        .unwrap();
773        assert!(matches!(operator.browser.arguments, Cow::Owned(_)));
774        assert!(matches!(operator.browser.start_page, Cow::Owned(_)));
775        operator.browser.arguments.to_mut().clear();
776        operator.browser.start_page.to_mut().push_str("/start");
777        operator.validate().unwrap();
778    }
779
780    #[test]
781    fn root_browser_requires_an_explicit_sandbox_opt_out() {
782        let mut browser = BrowserConfig::default();
783        let error = browser.validate_root_sandbox(true).unwrap_err();
784        assert!(
785            error
786                .to_string()
787                .contains("run the gateway as a non-root user")
788        );
789        assert!(
790            error
791                .to_string()
792                .contains("computer.browser.sandbox = false")
793        );
794        browser.validate_root_sandbox(false).unwrap();
795        browser.sandbox = false;
796        browser.validate_root_sandbox(true).unwrap();
797    }
798
799    #[test]
800    fn typed_defaults_preserve_pinned_runtime_and_desktop_policy() {
801        let defaults = ComputerConfig::default();
802        assert_eq!(defaults.install_timeout_seconds, 600);
803        assert_eq!(defaults.browser.window_size, [1200, 640]);
804        assert_eq!(defaults.browser.window_position, [80, 42]);
805        assert_eq!(defaults.browser.viewport, [1365, 768]);
806        assert_eq!(defaults.desktop.resolution, [1365, 768]);
807        assert_eq!(defaults.desktop.depth, 24);
808        assert_eq!(
809            (defaults.desktop.display_start, defaults.desktop.display_end),
810            (100, 200)
811        );
812        assert_eq!(defaults.desktop.tools.xvnc.as_ref(), ["Xtigervnc", "Xvnc"]);
813        assert_eq!(node_version(), "26.8.1");
814        assert_eq!(
815            node_distribution("linux", "aarch64").unwrap(),
816            (
817                "linux-arm64",
818                "d5f973ce975e4bd03e6c2038260f7e9201615aa8e1ee293c72f8dcc2a6d9fddb"
819            )
820        );
821        assert_eq!(
822            node_distribution("linux", "x86_64").unwrap(),
823            (
824                "linux-x64",
825                "b2b76660fa4ded4e0b2a41ee3c0c651cd52ea8170ead91ebac1e147ac3d55643"
826            )
827        );
828        assert_eq!(
829            node_distribution("macos", "aarch64").unwrap(),
830            (
831                "darwin-arm64",
832                "6e577fd0d9db776db82306629e441a9dace416702622aebdd171c9dfaa41f4d2"
833            )
834        );
835        assert_eq!(
836            node_distribution("macos", "x86_64").unwrap(),
837            (
838                "darwin-x64",
839                "fe9c6dbf9c8e1b4443803d75e2a20366e420dae650c747dbb116b22975751baf"
840            )
841        );
842    }
843
844    #[test]
845    fn embedded_defaults_require_operational_fields_without_public_default_recursion() {
846        let incomplete = DEFAULTS_TEXT.replace("viewport = [1365, 768]", "");
847        let error = toml::from_str::<ComputerDefaults>(&incomplete)
848            .err()
849            .unwrap();
850        assert!(error.to_string().contains("viewport"));
851    }
852
853    #[test]
854    fn partial_policy_keeps_manifest_defaults_and_requires_explicit_sandbox_disable() {
855        let mut config: ComputerConfig =
856            toml::from_str("[browser]\nviewport = [800, 600]\n").unwrap();
857        config.validate().unwrap();
858        assert_eq!(config.browser.viewport, [800, 600]);
859        assert!(
860            !config
861                .browser
862                .headed_arguments(Path::new("/tmp/profile"))
863                .contains(&"--no-sandbox".into())
864        );
865        config.browser.sandbox = false;
866        assert!(
867            config
868                .browser
869                .headed_arguments(Path::new("/tmp/profile"))
870                .contains(&"--no-sandbox".into())
871        );
872    }
873
874    #[test]
875    fn operator_arguments_cannot_replace_the_private_control_boundary() {
876        let mut config = ComputerConfig::default();
877        for argument in [
878            "--remote-debugging-address=0.0.0.0",
879            "--user-data-dir=/tmp/public",
880            "--no-sandbox",
881            "--remote-debugging-pipe",
882        ] {
883            config.browser.arguments = vec![argument.into()].into();
884            assert!(config.validate().is_err(), "{argument}");
885        }
886    }
887
888    #[test]
889    fn disabled_or_missing_default_application_is_optional() {
890        let application = DesktopApplication {
891            enabled: false,
892            ..DesktopApplication::default()
893        };
894        for role in [
895            ApplicationRole::WindowManager,
896            ApplicationRole::Panel,
897            ApplicationRole::Wallpaper,
898            ApplicationRole::Terminal,
899            ApplicationRole::Files,
900        ] {
901            assert!(super::application(&application, role).unwrap().is_none());
902        }
903        assert!(resolve_executable(Path::new("relative/path")).is_err());
904    }
905}