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