Skip to main content

glass/
capabilities.rs

1//! Versioned Glass protocol and capability negotiation.
2//!
3//! MCP remains the transport envelope, while this manifest describes the
4//! Glass contracts carried by that envelope. Clients may omit the request for
5//! backward compatibility; newer clients can request exact schema versions
6//! and receive a typed negotiation error before using optional features.
7
8use crate::browser::policy::{
9    BrowserPolicy, POLICY_SCHEMA_VERSION, PolicyCapability, PolicyDecision, PolicyPreset,
10};
11use crate::browser::session::{
12    INTENT_RESOLUTION_SCHEMA_VERSION, KNOWLEDGE_SCHEMA_VERSION,
13    SEMANTIC_OBSERVATION_SCHEMA_VERSION, WORKFLOW_AUTHORING_SCHEMA_VERSION,
14    WORKFLOW_SCHEMA_VERSION,
15};
16use crate::extensions::{ExtensionSandbox, experimental_extension_target_supported};
17use crate::reliability::{
18    RELIABILITY_FIXTURE_SCHEMA_VERSION, RELIABILITY_REPLAY_SCHEMA_VERSION,
19    RELIABILITY_SCENARIO_SCHEMA_VERSION,
20};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt;
25
26/// Stable Glass protocol version negotiated independently from MCP.
27pub use crate::protocol::GLASS_PROTOCOL_VERSION;
28
29/// A bounded, machine-readable description of one Glass runtime.
30///
31/// This is discovery output. It intentionally describes the complete runtime
32/// inventory and is kept separate from [`NegotiatedCapabilities`], which is the
33/// contract a client may rely on after initialization.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct GlassCapabilityManifest {
37    pub protocol_version: u32,
38    pub glass_version: String,
39    pub schemas: BTreeMap<String, Vec<u32>>,
40    pub capabilities: BTreeMap<String, bool>,
41    #[serde(default)]
42    pub capability_statuses: BTreeMap<String, GlassCapabilityStatus>,
43    pub constraints: GlassCapabilityConstraints,
44}
45
46/// The immutable capability contract selected for one connection.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct NegotiatedCapabilities {
50    pub protocol_version: u32,
51    pub agreed_schemas: BTreeMap<String, u32>,
52    pub capabilities: BTreeMap<String, NegotiatedCapability>,
53    pub constraints: GlassCapabilityConstraints,
54}
55
56/// Effective status for one capability in a negotiated agreement.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct NegotiatedCapability {
60    pub status: GlassCapabilityStatus,
61}
62
63impl NegotiatedCapability {
64    fn from_status(status: GlassCapabilityStatus) -> Self {
65        Self { status }
66    }
67}
68
69/// Why a capability is or is not available in the current runtime.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub enum GlassCapabilityStatus {
73    Available,
74    AvailableUncertified,
75    Experimental,
76    DisabledByPolicy,
77    UnavailableOnPlatform,
78    MissingRuntimeDependency,
79    BlockedBySecurityGate,
80    #[serde(other)]
81    Unknown,
82}
83
84/// Client requirements used to form an effective Glass agreement.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct CapabilityNegotiationRequest {
88    #[serde(default)]
89    pub protocol_versions: Vec<u32>,
90    #[serde(default)]
91    pub protocol_version: Option<u32>,
92    #[serde(default)]
93    pub schemas: BTreeMap<String, Vec<u32>>,
94    #[serde(default)]
95    pub requires: Vec<String>,
96    #[serde(default)]
97    pub optional: Vec<String>,
98    #[serde(default)]
99    pub accepts_experimental: bool,
100}
101
102/// Runtime and policy constraints that affect optional operations.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct GlassCapabilityConstraints {
106    pub platform: String,
107    pub browser_family: String,
108    pub policy: String,
109    pub max_sessions: u32,
110}
111
112impl GlassCapabilityManifest {
113    /// Reject impossible capability boolean/status combinations.
114    pub fn validate(&self) -> Result<(), CapabilityNegotiationError> {
115        for (name, enabled) in &self.capabilities {
116            let Some(status) = self.capability_statuses.get(name) else {
117                continue;
118            };
119            let blocking = matches!(
120                status,
121                GlassCapabilityStatus::DisabledByPolicy
122                    | GlassCapabilityStatus::UnavailableOnPlatform
123                    | GlassCapabilityStatus::MissingRuntimeDependency
124                    | GlassCapabilityStatus::BlockedBySecurityGate
125            );
126            if *enabled && blocking {
127                return Err(CapabilityNegotiationError {
128                    field: format!("glass.capabilityStatuses.{name}"),
129                    detail: format!("enabled capability cannot have blocking status {status:?}"),
130                });
131            }
132            if matches!(status, GlassCapabilityStatus::Unknown) {
133                return Err(CapabilityNegotiationError {
134                    field: format!("glass.capabilityStatuses.{name}"),
135                    detail: "unknown capability status is not negotiable".into(),
136                });
137            }
138        }
139        Ok(())
140    }
141}
142
143/// A bounded failure returned before an unsupported contract is used.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct CapabilityNegotiationError {
146    pub field: String,
147    pub detail: String,
148}
149
150impl fmt::Display for CapabilityNegotiationError {
151    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(formatter, "{}: {}", self.field, self.detail)
153    }
154}
155
156impl std::error::Error for CapabilityNegotiationError {}
157
158fn normalize_request(
159    value: &Value,
160) -> Result<CapabilityNegotiationRequest, CapabilityNegotiationError> {
161    let mut request: CapabilityNegotiationRequest =
162        serde_json::from_value(value.clone()).map_err(|error| CapabilityNegotiationError {
163            field: "glass".into(),
164            detail: format!("invalid capability request: {error}"),
165        })?;
166    if request.protocol_versions.is_empty() {
167        request.protocol_versions = request.protocol_version.into_iter().collect();
168    }
169    if request.protocol_versions.is_empty() {
170        request.protocol_versions.push(default_protocol_version());
171    }
172    Ok(request)
173}
174
175impl GlassCapabilityManifest {
176    /// Build the manifest for the current binary and policy.
177    pub fn for_policy(policy: &BrowserPolicy) -> Self {
178        Self::for_policy_in_mode(policy, false)
179    }
180
181    /// Build a manifest for an explicit runtime mode.
182    pub fn for_policy_in_mode(policy: &BrowserPolicy, local_daemon: bool) -> Self {
183        Self::for_policy_in_mode_with_experimental_extensions(policy, local_daemon, false)
184    }
185
186    /// Build a manifest with an explicit experimental extension opt-in.
187    pub fn for_policy_with_experimental_extensions(
188        policy: &BrowserPolicy,
189        experimental_extensions: bool,
190    ) -> Self {
191        Self::for_policy_in_mode_with_experimental_extensions(
192            policy,
193            false,
194            experimental_extensions,
195        )
196    }
197
198    /// Build a manifest for an explicit runtime mode and extension opt-in.
199    pub fn for_policy_in_mode_with_experimental_extensions(
200        policy: &BrowserPolicy,
201        local_daemon: bool,
202        experimental_extensions: bool,
203    ) -> Self {
204        let extensions_enabled = experimental_extensions
205            && experimental_extension_target_supported()
206            && !matches!(ExtensionSandbox::detect(), ExtensionSandbox::Unavailable);
207        let raw_cdp = matches!(
208            policy.decide(PolicyCapability::RawCdp),
209            PolicyDecision::Allow
210        );
211        let persistent_profile = matches!(
212            policy.decide(PolicyCapability::PersistentProfile),
213            PolicyDecision::Allow
214        );
215        let mut capabilities = BTreeMap::from([
216            ("action", true),
217            ("semanticRegions", true),
218            ("observationDiffs", true),
219            ("intentResolution", true),
220            ("workflowRuntime", true),
221            ("workflowResume", true),
222            ("persistentKnowledge", persistent_profile),
223            ("workflowAuthoring", true),
224            ("reliabilityCertification", true),
225            ("rawCdp", raw_cdp),
226            ("localDaemon", local_daemon),
227            ("extensions", extensions_enabled),
228        ])
229        .into_iter()
230        .map(|(name, enabled)| (name.to_string(), enabled))
231        .collect::<BTreeMap<_, _>>();
232        capabilities.insert("mcpStdio".into(), true);
233        let capability_statuses = capabilities
234            .iter()
235            .map(|(name, enabled)| {
236                let status = match name.as_str() {
237                    "extensions" if *enabled => GlassCapabilityStatus::Experimental,
238                    "extensions" => {
239                        if experimental_extensions {
240                            GlassCapabilityStatus::BlockedBySecurityGate
241                        } else {
242                            GlassCapabilityStatus::DisabledByPolicy
243                        }
244                    }
245                    "rawCdp" | "persistentKnowledge" if !enabled => {
246                        GlassCapabilityStatus::DisabledByPolicy
247                    }
248                    _ if *enabled => GlassCapabilityStatus::Available,
249                    _ => GlassCapabilityStatus::AvailableUncertified,
250                };
251                (name.clone(), status)
252            })
253            .collect();
254        Self {
255            protocol_version: GLASS_PROTOCOL_VERSION,
256            glass_version: env!("CARGO_PKG_VERSION").into(),
257            schemas: supported_schemas(),
258            capabilities,
259            capability_statuses,
260            constraints: GlassCapabilityConstraints {
261                platform: platform_label().into(),
262                browser_family: "chromium".into(),
263                policy: policy_label(policy.preset()).into(),
264                max_sessions: 4,
265            },
266        }
267    }
268
269    /// Negotiate a bounded, immutable agreement against this manifest.
270    ///
271    /// `None` retains backwards-compatible discovery semantics by agreeing to
272    /// the server's complete inventory. A request selects exact schema
273    /// versions and may require or optionally accept named capabilities.
274    pub fn negotiate(
275        &self,
276        request: Option<&Value>,
277    ) -> Result<NegotiatedCapabilities, CapabilityNegotiationError> {
278        self.validate()?;
279        let request = match request {
280            Some(value) => normalize_request(value)?,
281            None => CapabilityNegotiationRequest {
282                protocol_versions: vec![self.protocol_version],
283                protocol_version: None,
284                schemas: BTreeMap::new(),
285                requires: Vec::new(),
286                optional: self.capabilities.keys().cloned().collect(),
287                accepts_experimental: true,
288            },
289        };
290        if !request.protocol_versions.contains(&self.protocol_version) {
291            return Err(CapabilityNegotiationError {
292                field: "glass.protocolVersions".into(),
293                detail: format!(
294                    "unsupported protocols {:?}; expected {}",
295                    request.protocol_versions, self.protocol_version
296                ),
297            });
298        }
299
300        let mut agreed_schemas = BTreeMap::new();
301        for (name, requested_versions) in request.schemas {
302            let Some(supported_versions) = self.schemas.get(&name) else {
303                return Err(CapabilityNegotiationError {
304                    field: format!("glass.schemas.{name}"),
305                    detail: "unknown schema".into(),
306                });
307            };
308            let Some(version) = requested_versions
309                .iter()
310                .filter(|version| supported_versions.contains(version))
311                .max()
312                .copied()
313            else {
314                return Err(CapabilityNegotiationError {
315                    field: format!("glass.schemas.{name}"),
316                    detail: format!(
317                        "requested versions do not intersect supported versions {supported_versions:?}"
318                    ),
319                });
320            };
321            agreed_schemas.insert(name, version);
322        }
323        if agreed_schemas.is_empty() {
324            agreed_schemas = self
325                .schemas
326                .iter()
327                .filter_map(|(name, versions)| {
328                    versions
329                        .iter()
330                        .max()
331                        .map(|version| (name.clone(), *version))
332                })
333                .collect();
334        }
335
336        let required = request.requires.into_iter().collect::<BTreeSet<_>>();
337        let optional = request.optional.into_iter().collect::<BTreeSet<_>>();
338        let requested_capabilities: BTreeSet<String> = if required.is_empty() && optional.is_empty()
339        {
340            self.capabilities.keys().cloned().collect()
341        } else {
342            required.union(&optional).cloned().collect()
343        };
344        let mut capabilities = BTreeMap::new();
345        for name in requested_capabilities {
346            let Some(enabled) = self.capabilities.get(&name) else {
347                if required.contains(&name) {
348                    return Err(CapabilityNegotiationError {
349                        field: format!("glass.requires.{name}"),
350                        detail: "unknown capability".into(),
351                    });
352                }
353                continue;
354            };
355            let status = self
356                .capability_statuses
357                .get(&name)
358                .copied()
359                .unwrap_or(if *enabled {
360                    GlassCapabilityStatus::Available
361                } else {
362                    GlassCapabilityStatus::UnavailableOnPlatform
363                });
364            if required.contains(&name) {
365                if !*enabled {
366                    return Err(CapabilityNegotiationError {
367                        field: format!("glass.requires.{name}"),
368                        detail: format!("capability is not enabled ({status:?})"),
369                    });
370                }
371                if status == GlassCapabilityStatus::Experimental && !request.accepts_experimental {
372                    return Err(CapabilityNegotiationError {
373                        field: format!("glass.requires.{name}"),
374                        detail: "experimental capability requires acceptsExperimental=true".into(),
375                    });
376                }
377            } else if status == GlassCapabilityStatus::Experimental && !request.accepts_experimental
378            {
379                continue;
380            }
381            capabilities.insert(name, NegotiatedCapability::from_status(status));
382        }
383
384        Ok(NegotiatedCapabilities {
385            protocol_version: self.protocol_version,
386            agreed_schemas,
387            capabilities,
388            constraints: self.constraints.clone(),
389        })
390    }
391}
392
393fn supported_schemas() -> BTreeMap<String, Vec<u32>> {
394    BTreeMap::from([
395        ("protocol".into(), vec![GLASS_PROTOCOL_VERSION]),
396        ("action".into(), vec![1]),
397        (
398            "observation".into(),
399            vec![SEMANTIC_OBSERVATION_SCHEMA_VERSION],
400        ),
401        ("workflow".into(), vec![WORKFLOW_SCHEMA_VERSION]),
402        ("checkpoint".into(), vec![1]),
403        ("policy".into(), vec![POLICY_SCHEMA_VERSION]),
404        ("workflowCheckpoint".into(), vec![1]),
405        ("trace".into(), vec![1]),
406        ("intent".into(), vec![INTENT_RESOLUTION_SCHEMA_VERSION]),
407        ("knowledge".into(), vec![KNOWLEDGE_SCHEMA_VERSION]),
408        ("authoring".into(), vec![WORKFLOW_AUTHORING_SCHEMA_VERSION]),
409        (
410            "reliabilityScenario".into(),
411            vec![RELIABILITY_SCENARIO_SCHEMA_VERSION],
412        ),
413        (
414            "reliabilityFixture".into(),
415            vec![RELIABILITY_FIXTURE_SCHEMA_VERSION],
416        ),
417        (
418            "reliabilityReplay".into(),
419            vec![RELIABILITY_REPLAY_SCHEMA_VERSION],
420        ),
421    ])
422}
423
424fn default_protocol_version() -> u32 {
425    GLASS_PROTOCOL_VERSION
426}
427
428fn policy_label(policy: PolicyPreset) -> &'static str {
429    match policy {
430        PolicyPreset::Development => "development",
431        PolicyPreset::Ci => "ci",
432        PolicyPreset::Polite => "polite",
433        PolicyPreset::Hardened => "hardened",
434        PolicyPreset::UntrustedMcp => "untrusted-mcp",
435    }
436}
437
438fn platform_label() -> &'static str {
439    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
440    {
441        "linux-x86_64"
442    }
443    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
444    {
445        "linux-arm64"
446    }
447    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
448    {
449        "macos-x86_64"
450    }
451    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
452    {
453        "macos-aarch64"
454    }
455    #[cfg(not(any(
456        all(target_os = "linux", target_arch = "x86_64"),
457        all(target_os = "linux", target_arch = "aarch64"),
458        all(target_os = "macos", target_arch = "x86_64"),
459        all(target_os = "macos", target_arch = "aarch64")
460    )))]
461    {
462        "unsupported"
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn development_manifest() -> GlassCapabilityManifest {
471        let policy = BrowserPolicy::development(std::env::current_dir().unwrap()).unwrap();
472        GlassCapabilityManifest::for_policy(&policy)
473    }
474
475    #[test]
476    fn manifest_is_stable_and_lists_current_contracts() {
477        let manifest = development_manifest();
478
479        assert_eq!(manifest.protocol_version, 1);
480        assert_eq!(manifest.schemas["workflow"], vec![1]);
481        assert_eq!(manifest.schemas["reliabilityReplay"], vec![1]);
482        assert_eq!(manifest.constraints.max_sessions, 4);
483        assert!(manifest.capabilities["workflowResume"]);
484        assert!(!manifest.capabilities["localDaemon"]);
485        assert_eq!(
486            manifest.capability_statuses["extensions"],
487            GlassCapabilityStatus::DisabledByPolicy
488        );
489        assert_eq!(
490            manifest.capability_statuses["rawCdp"],
491            GlassCapabilityStatus::Available
492        );
493    }
494
495    #[test]
496    fn experimental_extensions_require_opt_in_and_a_native_sandbox() {
497        let policy = BrowserPolicy::development(std::env::current_dir().unwrap()).unwrap();
498        let manifest =
499            GlassCapabilityManifest::for_policy_with_experimental_extensions(&policy, true);
500        if !experimental_extension_target_supported()
501            || matches!(ExtensionSandbox::detect(), ExtensionSandbox::Unavailable)
502        {
503            assert!(!manifest.capabilities["extensions"]);
504            assert_eq!(
505                manifest.capability_statuses["extensions"],
506                GlassCapabilityStatus::BlockedBySecurityGate
507            );
508        } else {
509            assert!(manifest.capabilities["extensions"]);
510            assert_eq!(
511                manifest.capability_statuses["extensions"],
512                GlassCapabilityStatus::Experimental
513            );
514        }
515    }
516
517    #[test]
518    fn negotiation_returns_effective_schema_and_capability_agreement() {
519        let manifest = development_manifest();
520        let request = serde_json::json!({
521            "protocolVersions": [99, 1],
522            "schemas": {"action": [99, 1], "workflow": [1]},
523            "requires": ["workflowResume"],
524            "optional": ["extensions"],
525            "acceptsExperimental": false,
526            "futureField": true
527        });
528
529        let agreement = manifest.negotiate(Some(&request)).unwrap();
530        assert_eq!(agreement.protocol_version, 1);
531        assert_eq!(agreement.agreed_schemas["action"], 1);
532        assert_eq!(agreement.agreed_schemas["workflow"], 1);
533        assert_eq!(
534            agreement.capabilities["workflowResume"].status,
535            GlassCapabilityStatus::Available
536        );
537        assert_eq!(
538            agreement.capabilities["extensions"].status,
539            GlassCapabilityStatus::DisabledByPolicy
540        );
541        assert_eq!(manifest.schemas["workflow"], vec![1]);
542    }
543
544    #[test]
545    fn negotiation_rejects_unknown_and_incompatible_requests() {
546        let manifest = development_manifest();
547        let unknown_schema = serde_json::json!({
548            "protocolVersion": 1,
549            "schemas": {"future": [1]}
550        });
551        let incompatible_schema = serde_json::json!({
552            "protocolVersion": 1,
553            "schemas": {"workflow": [99]}
554        });
555        let missing_required_capability = serde_json::json!({
556            "protocolVersions": [1],
557            "requires": ["extensions"]
558        });
559
560        assert!(manifest.negotiate(Some(&unknown_schema)).is_err());
561        assert!(manifest.negotiate(Some(&incompatible_schema)).is_err());
562        assert!(
563            manifest
564                .negotiate(Some(&missing_required_capability))
565                .is_err()
566        );
567    }
568
569    #[test]
570    fn manifest_validation_rejects_enabled_blocked_capabilities() {
571        let mut manifest = development_manifest();
572        manifest.capabilities.insert("broken".into(), true);
573        manifest.capability_statuses.insert(
574            "broken".into(),
575            GlassCapabilityStatus::BlockedBySecurityGate,
576        );
577
578        let error = manifest.validate().unwrap_err();
579        assert_eq!(error.field, "glass.capabilityStatuses.broken");
580    }
581
582    #[test]
583    fn unknown_manifest_fields_and_statuses_are_tolerated_on_decode() {
584        let value = serde_json::json!({
585            "protocolVersion": 1,
586            "glassVersion": "0.2.2",
587            "schemas": {},
588            "capabilities": {"future": false},
589            "capabilityStatuses": {"future": "addedLater"},
590            "constraints": {
591                "platform": "linux-arm64",
592                "browserFamily": "chromium",
593                "policy": "development",
594                "maxSessions": 4,
595                "futureConstraint": true
596            },
597            "futureManifestField": true
598        });
599        let manifest: GlassCapabilityManifest = serde_json::from_value(value).unwrap();
600        assert_eq!(
601            manifest.capability_statuses["future"],
602            GlassCapabilityStatus::Unknown
603        );
604        assert!(manifest.validate().is_err());
605    }
606}