Skip to main content

mobius_gateway/
config.rs

1//! Validated gateway configuration and owner-only persistence.
2
3mod runtime;
4mod store;
5mod validation;
6mod workspace;
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::env;
10use std::fs;
11use std::io::Read as _;
12use std::net::SocketAddr;
13#[cfg(unix)]
14use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
15use std::path::{Component, Path, PathBuf};
16use std::sync::Mutex;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use mobius::agent::DEFAULT_MAX_MODEL_STEPS;
20use mobius::backend::model::provider::{
21    ProviderAuth, ProviderDefinition, default_provider, provider,
22};
23use mobius::protocol::TokenUsage;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use sha2::Digest as _;
27
28#[cfg(test)]
29use crate::wire::RoutineInteractionPolicy;
30use crate::wire::{
31    AgentComposition, DailyUsage, ProfileSnapshot, ProviderConfig, ProviderEndpointAuth,
32    ProviderTint, VersionedAgentConfig, WorkspaceInfo,
33};
34use crate::{Error, Result};
35
36pub use self::runtime::RuntimeConfig;
37use self::store::*;
38pub use self::store::{
39    ConfigStore, CredentialStore, ResolvedCredential, load_secret_file, state_dir,
40};
41pub use self::validation::validate_agent_composition;
42use self::validation::*;
43pub(crate) use self::validation::{
44    configured_approval_policy, effective_reasoning_effort, model_route_id,
45    validate_bot_compatibility, validate_desktop_bot_policy,
46};
47pub(crate) use self::workspace::{
48    create_workspace_directory, local_user_name, validate_chat_workspace, workspace_id,
49};
50pub use crate::server::ConnectionPolicy;
51
52const CONFIG_VERSION: u32 = 26;
53pub(crate) const MAX_CAPACITY: usize = 4_096;
54
55pub(crate) fn bounded<T>(name: &str, value: T, range: std::ops::RangeInclusive<T>) -> Result<()>
56where
57    T: Copy + PartialOrd + std::fmt::Display,
58{
59    if !range.contains(&value) {
60        return Err(Error::Config(format!(
61            "{name} must be between {} and {}",
62            range.start(),
63            range.end()
64        )));
65    }
66    Ok(())
67}
68const CHAT_SPEC_VERSION: u32 = 15;
69pub(crate) const CHAT_SPEC_METADATA_KEY: &str = "mobius_gateway.chat";
70const CONFIG_FILE: &str = "gateway.toml";
71const CLOUDFLARE_TOKEN_FILE: &str = "cloudflare-token";
72const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
73const MAX_CREDENTIAL_STATE_BYTES: usize = 256 * 1024;
74const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024;
75/// Maximum UTF-8 byte length accepted for a provider credential.
76pub const MAX_PROVIDER_API_KEY_BYTES: usize = 16 * 1024;
77const MAX_PROVIDER_CATALOG_ENTRIES: usize = 64;
78const MAX_PROVIDER_CATALOG_ENTRY_BYTES: usize = 1024;
79const MAX_PROVIDER_CATALOG_BYTES: usize = 16 * 1024;
80const MAX_CUSTOM_MODEL_ROUTES: usize = 64;
81/// Maximum UTF-8 byte length accepted for a Cloudflare tunnel token.
82pub const MAX_CLOUDFLARE_TOKEN_BYTES: usize = 16 * 1024;
83/// Maximum UTF-8 byte length accepted for a provider label.
84pub const MAX_PROVIDER_LABEL_BYTES: usize = 128;
85const MAX_WORKSPACE_DIRECTORY_NAME_BYTES: usize = 255;
86const MAX_ATTACHED_FOLDERS: usize = 8;
87const SECONDS_PER_DAY: u64 = 86_400;
88const USAGE_HISTORY_DAYS: u64 = 52 * 7;
89
90/// Default loopback listener used by a local gateway.
91pub const DEFAULT_LISTEN: SocketAddr =
92    SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8741);
93
94/// Default system prompt installed by `mobius-gateway init`.
95pub const DEFAULT_SYSTEM_PROMPT: &str = include_str!("config/default_prompt.md");
96
97/// Context window used for custom models without an advertised preset.
98pub const DEFAULT_CONTEXT_WINDOW: i64 = 272_000;
99
100/// Certificate paths required by a TLS listener.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct TlsConfig {
104    /// The certificate.
105    pub certificate: PathBuf,
106    /// The private key.
107    pub private_key: PathBuf,
108}
109
110/// Cloudflare Tunnel exposure selected for this gateway.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
113pub enum CloudflareConfig {
114    /// Account-free tunnel with an address assigned at process startup.
115    Quick,
116    /// User-owned tunnel with a stable published hostname.
117    Named {
118        /// The published tunnel hostname.
119        hostname: String,
120    },
121}
122
123/// Durable machine-wide settings and defaults for one gateway process.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct GatewayConfig {
127    version: u32,
128    /// Gateway lifecycle and private ingress.
129    #[serde(default)]
130    pub runtime: RuntimeConfig,
131    /// Connection and active-chat resource limits.
132    #[serde(default)]
133    pub connections: ConnectionPolicy,
134    /// Operator-controlled pairing policy.
135    #[serde(default)]
136    pub auth: crate::auth::AuthConfig,
137    /// Computer runtime and desktop launch options.
138    #[serde(default)]
139    pub computer: crate::computer_runtime::ComputerConfig,
140    /// Model network and retry policy.
141    #[serde(default)]
142    pub model_transport: mobius::backend::model::ModelTransportSettings,
143    /// Host execution policy independent of Bot permissions.
144    #[serde(default)]
145    pub execution: crate::sandbox::ExecutionConfig,
146    /// Explicit outbound collectors.
147    #[serde(default)]
148    pub telemetry: crate::telemetry::TelemetryConfig,
149    /// The listen.
150    pub listen: SocketAddr,
151    /// The TLS.
152    pub tls: Option<TlsConfig>,
153    /// The cloudflare.
154    pub cloudflare: Option<CloudflareConfig>,
155    /// Own a persistent headed browser and, on Linux, a private virtual desktop.
156    #[serde(default)]
157    pub desktop_enabled: bool,
158    /// The bot defaults.
159    pub bot_defaults: Option<VersionedAgentConfig>,
160    pub(crate) configured_providers: BTreeMap<String, ConfiguredProvider>,
161    pub(crate) installed_extensions: BTreeMap<String, crate::extensions::InstalledExtension>,
162    usage: UsageHistory,
163}
164
165/// One durable provider selection and its gateway model and reasoning catalogs.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub(crate) struct ConfiguredProvider {
169    pub(crate) selection: ProviderConfig,
170    pub(crate) label: String,
171    pub(crate) tint: ProviderTint,
172    pub(crate) model_ids: Vec<String>,
173    pub(crate) reasoning_efforts: Vec<String>,
174}
175
176/// Chat ownership and workspace, independent of Bot configuration.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub(crate) struct ChatSpec {
179    version: u32,
180    pub(crate) workspace: Option<PathBuf>,
181    pub(crate) attached_folders: Vec<PathBuf>,
182    pub(crate) bot_id: String,
183    pub(crate) catalog_visible: bool,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(deny_unknown_fields)]
188struct StoredChatSpec {
189    version: u32,
190    workspace: Option<PathBuf>,
191    attached_folders: Vec<PathBuf>,
192    bot_id: String,
193}
194
195impl Default for AgentComposition {
196    fn default() -> Self {
197        let provider = default_provider();
198        let model = provider
199            .default_model()
200            .and_then(|id| provider.model(id))
201            .expect("default model manifest");
202        let mut middleware = crate::middleware_manifest::default_config();
203        middleware.set_setting(
204            "sandbox",
205            "approval_policy",
206            Some(mobius::protocol::FrontendSettingValue::String(
207                "full_access".into(),
208            )),
209        );
210        Self {
211            provider: ProviderConfig {
212                instance: provider.id().into(),
213                provider: provider.id().into(),
214                model: model.id.clone(),
215                base_url: provider.default_base_url().map(str::to_string),
216                endpoint_auth: ProviderEndpointAuth::ProviderDefault,
217                reasoning_effort: model.default_reasoning.clone(),
218                service_tier: None,
219                web_search: *provider
220                    .web_search()
221                    .first()
222                    .expect("default provider web-search manifest"),
223            },
224            realtime_voice: None,
225            middleware,
226            extensions: BTreeSet::new(),
227            system_prompt: DEFAULT_SYSTEM_PROMPT.into(),
228            max_model_steps: DEFAULT_MAX_MODEL_STEPS as u64,
229        }
230    }
231}
232
233impl GatewayConfig {
234    /// Builds validated machine-wide settings and Bot-creation defaults.
235    /// # Errors
236    ///
237    /// Returns an error if configuration is invalid or a required resource cannot be initialized.
238    pub fn new(listen: SocketAddr, tls: Option<TlsConfig>) -> Result<Self> {
239        let config = Self {
240            version: CONFIG_VERSION,
241            runtime: Default::default(),
242            connections: Default::default(),
243            auth: Default::default(),
244            computer: Default::default(),
245            model_transport: Default::default(),
246            execution: Default::default(),
247            telemetry: Default::default(),
248            listen,
249            tls,
250            cloudflare: None,
251            desktop_enabled: false,
252            bot_defaults: None,
253            configured_providers: BTreeMap::new(),
254            installed_extensions: BTreeMap::new(),
255            usage: UsageHistory::default(),
256        };
257        config.validate()?;
258        Ok(config)
259    }
260
261    /// Builds a loopback gateway exposed through Cloudflare Tunnel.
262    /// # Errors
263    ///
264    /// Returns an error if configuration is invalid or a required resource cannot be initialized.
265    pub fn new_cloudflare(listen: SocketAddr, cloudflare: CloudflareConfig) -> Result<Self> {
266        let mut config = Self::new(listen, None)?;
267        config.cloudflare = Some(cloudflare);
268        config.validate()?;
269        Ok(config)
270    }
271
272    /// Registers one configured provider and establishes the first Bot defaults.
273    pub(crate) fn registering_provider(
274        &self,
275        selection: ProviderConfig,
276        label: String,
277        tint: ProviderTint,
278        model_ids: Vec<String>,
279        reasoning_efforts: Vec<String>,
280    ) -> Result<Self> {
281        if let Some(configured) = self.configured_providers.get(&selection.instance)
282            && configured.selection.provider != selection.provider
283        {
284            return Err(Error::Config(format!(
285                "provider instance `{}` already belongs to `{}`",
286                selection.instance, configured.selection.provider
287            )));
288        }
289        let configured = ConfiguredProvider {
290            selection: selection.clone(),
291            label,
292            tint,
293            model_ids,
294            reasoning_efforts,
295        };
296        let mut next = self.clone();
297        next.configured_providers
298            .insert(selection.instance.clone(), configured);
299        if self.bot_defaults.is_none() {
300            let config = AgentComposition {
301                provider: selection,
302                ..AgentComposition::default()
303            };
304            next.bot_defaults = Some(VersionedAgentConfig {
305                revision: 1,
306                config,
307            });
308        }
309        next.validate()?;
310        Ok(next)
311    }
312
313    /// Removes one non-default provider and lets middleware inherit the primary model.
314    pub(crate) fn removing_provider(&self, instance: &str) -> Result<Self> {
315        if !self.configured_providers.contains_key(instance) {
316            return Err(Error::Config(format!(
317                "provider instance `{instance}` is not configured"
318            )));
319        }
320        if self
321            .bot_defaults
322            .as_ref()
323            .is_some_and(|default| default.config.provider.instance == instance)
324        {
325            return Err(Error::Config(
326                "choose another provider for Bot defaults before removing this provider".into(),
327            ));
328        }
329        let mut next = self.clone();
330        next.configured_providers.remove(instance);
331        let mut bot_defaults = next
332            .bot_defaults
333            .as_ref()
334            .expect("a removable provider cannot be the only configured provider")
335            .config
336            .clone();
337        if clear_missing_model_routes(&mut bot_defaults, &next)? {
338            let default = next
339                .bot_defaults
340                .as_mut()
341                .expect("a removable provider cannot be the only configured provider");
342            default.config = bot_defaults;
343            default.revision = default
344                .revision
345                .checked_add(1)
346                .ok_or_else(|| Error::Config("configuration revision overflow".into()))?;
347        }
348        next.validate()?;
349        Ok(next)
350    }
351
352    /// Replaces only the defaults copied into future chats.
353    pub(crate) fn replacing_bot_defaults(
354        &self,
355        expected_revision: u64,
356        composition: AgentComposition,
357    ) -> Result<Self> {
358        let current = self
359            .bot_defaults
360            .as_ref()
361            .ok_or_else(|| Error::Config("configure a provider before saving defaults".into()))?;
362        if current.revision != expected_revision {
363            return Err(Error::Config(format!(
364                "configuration revision changed from {expected_revision} to {}",
365                current.revision
366            )));
367        }
368        let mut next = self.clone();
369        next.bot_defaults = Some(VersionedAgentConfig {
370            revision: current
371                .revision
372                .checked_add(1)
373                .ok_or_else(|| Error::Config("configuration revision overflow".into()))?,
374            config: composition,
375        });
376        next.validate()?;
377        Ok(next)
378    }
379
380    pub(crate) fn validate_provider_selection(&self, selection: &ProviderConfig) -> Result<()> {
381        validate_provider_config(selection)?;
382        let configured = self
383            .configured_providers
384            .get(&selection.instance)
385            .ok_or_else(|| {
386                Error::Config("provider selection must use a configured provider entry".into())
387            })?;
388        validate_configured_provider_selection(configured, selection)
389    }
390
391    /// Records one live token-usage increment and reports whether daily usage changed.
392    /// # Errors
393    ///
394    /// Returns an error if validation or an operation required by this function fails.
395    pub fn observe_usage(&mut self, provider: &str, usage: &TokenUsage) -> Result<bool> {
396        self.usage.observe(provider, usage, SystemTime::now())
397    }
398
399    /// Returns frontend-safe local identity and daily aggregate usage.
400    #[must_use]
401    pub fn profile(&self) -> ProfileSnapshot {
402        ProfileSnapshot {
403            user_name: local_user_name(),
404            daily_usage: self
405                .usage
406                .days
407                .iter()
408                .flat_map(|(unix_day, providers)| {
409                    providers.iter().map(|(provider, usage)| DailyUsage {
410                        unix_day: *unix_day,
411                        provider: provider.clone(),
412                        usage: usage.clone(),
413                    })
414                })
415                .collect(),
416            provider_usage: Vec::new(),
417            run_stats: crate::wire::RunStats::default(),
418            recent_run_groups: Vec::new(),
419        }
420    }
421
422    /// Validates every persisted trust-boundary field.
423    /// # Errors
424    ///
425    /// Returns an error if the supplied value is invalid.
426    pub fn validate(&self) -> Result<()> {
427        if self.version != CONFIG_VERSION {
428            return Err(Error::Config(format!(
429                "unsupported gateway config version {}",
430                self.version
431            )));
432        }
433        validate_telemetry(&self.telemetry)?;
434        self.runtime.validate()?;
435        self.connections.validate()?;
436        self.auth.validate()?;
437        self.computer.validate()?;
438        self.model_transport.validate()?;
439        self.execution.validate()?;
440        if let Some(ingress) = self.runtime.ingress
441            && (ingress == self.listen
442                || ingress.port() == 0
443                || self.tls.is_some()
444                || self.cloudflare.is_some())
445        {
446            return Err(Error::Config(
447                "ingress must differ from listen and cannot use TLS or Cloudflare".into(),
448            ));
449        }
450        if self.listen.port() == 0 {
451            return Err(Error::Config(
452                "gateway listen port must be greater than zero".into(),
453            ));
454        }
455        match (&self.tls, self.listen.ip().is_loopback()) {
456            (None, false) => {
457                return Err(Error::Config(
458                    "non-loopback gateway listeners require a TLS certificate and private key"
459                        .into(),
460                ));
461            }
462            (Some(tls), _) => tls.validate()?,
463            (None, true) => {}
464        }
465        if self.cloudflare.is_some() && (!self.listen.ip().is_loopback() || self.tls.is_some()) {
466            return Err(Error::Config(
467                "Cloudflare gateways require a plaintext loopback listener".into(),
468            ));
469        }
470        if let Some(cloudflare) = &self.cloudflare {
471            cloudflare.validate()?;
472        }
473        if self.configured_providers.is_empty() != self.bot_defaults.is_none() {
474            return Err(Error::Config(
475                "Bot defaults must exist exactly when a provider is configured".into(),
476            ));
477        }
478        for (instance, configured) in &self.configured_providers {
479            if instance != &configured.selection.instance {
480                return Err(Error::Config(format!(
481                    "configured provider key `{instance}` does not match `{}`",
482                    configured.selection.instance
483                )));
484            }
485            validate_configured_provider(configured)?;
486        }
487        crate::extensions::validate_installed(&self.installed_extensions)?;
488        validate_custom_model_route_count(&self.configured_providers)?;
489        if let Some(default) = &self.bot_defaults {
490            validate_desktop_bot_policy(self, &default.config)?;
491            if default.revision == 0 {
492                return Err(Error::Config(
493                    "configuration revision must be positive".into(),
494                ));
495            }
496            validate_agent_composition_with_ceilings(
497                &default.config,
498                self.execution.subagent_ceilings()?,
499            )?;
500            self.validate_provider_selection(&default.config.provider)?;
501            for (middleware, setting, route) in
502                crate::middleware_manifest::configured_model_routes(&default.config.middleware)
503            {
504                if !crate::provider_catalog::configured_route_exists(self, route)? {
505                    return Err(Error::Config(format!(
506                        "Bot default middleware setting `{middleware}.{setting}` is not a configured model route"
507                    )));
508                }
509            }
510        }
511        for providers in self.usage.days.values() {
512            for (provider, usage) in providers {
513                validate_usage_provider(provider)?;
514                validate_usage(usage)?;
515            }
516        }
517        Ok(())
518    }
519}
520
521impl ChatSpec {
522    pub(crate) fn for_bot(
523        workspace: &Path,
524        bot: &crate::wire::BotRecord,
525        state_dir: &Path,
526        tls: Option<&TlsConfig>,
527    ) -> Result<Self> {
528        let spec = Self {
529            version: CHAT_SPEC_VERSION,
530            workspace: Some(validate_chat_workspace(workspace, state_dir, tls)?),
531            attached_folders: Vec::new(),
532            bot_id: bot.id.clone(),
533            catalog_visible: true,
534        };
535        spec.validate(state_dir, tls)?;
536        Ok(spec)
537    }
538
539    pub(crate) fn from_metadata(
540        metadata: &BTreeMap<String, Value>,
541        bots: &crate::bots::BotStore,
542        state_dir: &Path,
543        tls: Option<&TlsConfig>,
544    ) -> Result<Self> {
545        Self::from_metadata_if_present(metadata, bots, state_dir, tls)?.ok_or_else(|| {
546            Error::Config("chat checkpoint has no gateway runtime configuration".into())
547        })
548    }
549
550    pub(crate) fn from_metadata_if_present(
551        metadata: &BTreeMap<String, Value>,
552        bots: &crate::bots::BotStore,
553        state_dir: &Path,
554        tls: Option<&TlsConfig>,
555    ) -> Result<Option<Self>> {
556        let Some(value) = metadata.get(CHAT_SPEC_METADATA_KEY) else {
557            return Ok(None);
558        };
559        let stored: StoredChatSpec = serde_json::from_value(value.clone())?;
560        let bot = bots.bot(&stored.bot_id)?;
561        let spec = Self {
562            version: stored.version,
563            workspace: stored.workspace,
564            attached_folders: stored
565                .attached_folders
566                .into_iter()
567                .filter(|folder| folder.is_dir())
568                .collect(),
569            bot_id: bot.id,
570            catalog_visible: true,
571        };
572        spec.validate(state_dir, tls)?;
573        Ok(Some(spec))
574    }
575
576    pub(crate) fn metadata(&self) -> Result<BTreeMap<String, Value>> {
577        Ok(BTreeMap::from([(
578            CHAT_SPEC_METADATA_KEY.into(),
579            serde_json::to_value(StoredChatSpec {
580                version: self.version,
581                workspace: self.workspace.clone(),
582                attached_folders: self.attached_folders.clone(),
583                bot_id: self.bot_id.clone(),
584            })?,
585        )]))
586    }
587
588    pub(crate) fn persistent(bot: &crate::wire::BotRecord) -> Self {
589        Self {
590            version: CHAT_SPEC_VERSION,
591            workspace: None,
592            attached_folders: Vec::new(),
593            bot_id: bot.id.clone(),
594            catalog_visible: true,
595        }
596    }
597
598    /// Private execution cwd, independent of the logical project shown to clients.
599    pub(crate) fn execution_root(
600        &self,
601        state_dir: &Path,
602        tls: Option<&TlsConfig>,
603    ) -> Result<PathBuf> {
604        if let Some(workspace) = &self.workspace {
605            return Ok(workspace.clone());
606        }
607        let root = state_dir.with_extension("workspaces").join(&self.bot_id);
608        fs::create_dir_all(&root)?;
609        #[cfg(unix)]
610        fs::set_permissions(&root, mobius::owner_only::dir())?;
611        validate_chat_workspace(&root, state_dir, tls)
612    }
613
614    #[must_use]
615    pub(crate) fn workspace_info(&self) -> Option<WorkspaceInfo> {
616        self.workspace.as_ref().map(|path| WorkspaceInfo {
617            id: workspace_id(path),
618            path: path.clone(),
619        })
620    }
621
622    pub(crate) fn with_attached_folder(
623        &self,
624        folder: &Path,
625        state_dir: &Path,
626        tls: Option<&TlsConfig>,
627    ) -> Result<Option<Self>> {
628        let workspace = self.workspace.as_ref().ok_or_else(|| {
629            Error::Config(
630                "Persistent Chat has no project; open a project chat to attach folders".into(),
631            )
632        })?;
633        let folder = validate_chat_workspace(folder, state_dir, tls)?;
634        if &folder == workspace || self.attached_folders.contains(&folder) {
635            return Ok(None);
636        }
637        if self.attached_folders.len() == MAX_ATTACHED_FOLDERS {
638            return Err(Error::Config(format!(
639                "a chat cannot attach more than {MAX_ATTACHED_FOLDERS} folders"
640            )));
641        }
642        let mut next = self.clone();
643        next.attached_folders.push(folder);
644        next.validate(state_dir, tls)?;
645        Ok(Some(next))
646    }
647
648    fn validate(&self, state_dir: &Path, tls: Option<&TlsConfig>) -> Result<()> {
649        if self.version != CHAT_SPEC_VERSION {
650            return Err(Error::Config(format!(
651                "unsupported chat configuration version {}",
652                self.version
653            )));
654        }
655        if self.bot_id.is_empty() {
656            return Err(Error::Config("chat Bot ownership is invalid".into()));
657        }
658        if let Some(path) = &self.workspace {
659            let workspace = validate_chat_workspace(path, state_dir, tls)?;
660            if workspace != *path {
661                return Err(Error::Config(
662                    "chat workspace must use its canonical path".into(),
663                ));
664            }
665        } else if !self.attached_folders.is_empty() {
666            return Err(Error::Config(
667                "project-free chats cannot attach folders".into(),
668            ));
669        }
670        if self.attached_folders.len() > MAX_ATTACHED_FOLDERS {
671            return Err(Error::Config(format!(
672                "a chat cannot attach more than {MAX_ATTACHED_FOLDERS} folders"
673            )));
674        }
675        let mut folders = self.workspace.iter().collect::<BTreeSet<_>>();
676        for attached in &self.attached_folders {
677            let workspace = validate_chat_workspace(attached, state_dir, tls)?;
678            if workspace != *attached {
679                return Err(Error::Config(
680                    "attached folders must use canonical paths".into(),
681                ));
682            }
683            if !folders.insert(attached) {
684                return Err(Error::Config("chat folders must be unique".into()));
685            }
686        }
687        Ok(())
688    }
689}
690
691fn clear_missing_model_routes(
692    composition: &mut AgentComposition,
693    gateway: &GatewayConfig,
694) -> Result<bool> {
695    let routes = crate::middleware_manifest::configured_model_routes(&composition.middleware)
696        .into_iter()
697        .map(|(middleware, setting, route)| {
698            (middleware.to_owned(), setting.to_owned(), route.to_owned())
699        })
700        .collect::<Vec<_>>();
701    let mut changed = false;
702    for (middleware, setting, route) in routes {
703        if !crate::provider_catalog::configured_route_exists(gateway, &route)? {
704            composition
705                .middleware
706                .set_setting(middleware, setting, None);
707            changed = true;
708        }
709    }
710    Ok(changed)
711}
712
713impl TlsConfig {
714    fn validate(&self) -> Result<()> {
715        for (name, path) in [
716            ("TLS certificate", &self.certificate),
717            ("TLS private key", &self.private_key),
718        ] {
719            if !path.is_absolute() || !path.is_file() {
720                return Err(Error::Config(format!(
721                    "{name} must be an existing absolute file"
722                )));
723            }
724        }
725        Ok(())
726    }
727}
728
729impl CloudflareConfig {
730    /// Validates and normalizes a stable public hostname.
731    /// # Errors
732    ///
733    /// Returns an error if validation or an operation required by this function fails.
734    pub fn named(hostname: &str) -> Result<Self> {
735        let hostname = hostname.trim().to_ascii_lowercase();
736        let config = Self::Named { hostname };
737        config.validate()?;
738        Ok(config)
739    }
740
741    /// Returns the stable endpoint when one exists before startup.
742    #[must_use]
743    pub fn endpoint(&self) -> Option<String> {
744        self.hostname().map(|hostname| format!("wss://{hostname}"))
745    }
746
747    /// Returns the stable hostname when this is a named tunnel.
748    #[must_use]
749    pub fn hostname(&self) -> Option<&str> {
750        match self {
751            Self::Quick => None,
752            Self::Named { hostname } => Some(hostname),
753        }
754    }
755
756    /// Validates one tunnel-scoped connector token without retaining it.
757    /// # Errors
758    ///
759    /// Returns an error if the supplied value is invalid.
760    pub fn validate_token(token: &str) -> Result<()> {
761        validate_cloudflare_token(token).map(|_| ())
762    }
763
764    fn validate(&self) -> Result<()> {
765        if let Self::Named { hostname } = self
766            && (hostname.len() > 253
767                || !hostname.is_ascii()
768                || hostname != &hostname.to_ascii_lowercase()
769                || !hostname.contains('.')
770                || !hostname.split('.').all(crate::hostnames::valid_label))
771        {
772            return Err(invalid_cloudflare_hostname());
773        }
774        Ok(())
775    }
776}
777
778#[cfg(test)]
779mod tests;