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::reliability::{
17    RELIABILITY_FIXTURE_SCHEMA_VERSION, RELIABILITY_REPLAY_SCHEMA_VERSION,
18    RELIABILITY_SCENARIO_SCHEMA_VERSION,
19};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::fmt;
24
25/// Stable Glass protocol version negotiated independently from MCP.
26pub use crate::protocol::GLASS_PROTOCOL_VERSION;
27
28/// A bounded, machine-readable description of one Glass runtime.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct GlassCapabilityManifest {
32    pub protocol_version: u32,
33    pub glass_version: String,
34    pub schemas: BTreeMap<String, Vec<u32>>,
35    pub capabilities: BTreeMap<String, bool>,
36    pub constraints: GlassCapabilityConstraints,
37}
38
39/// Runtime and policy constraints that affect optional operations.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct GlassCapabilityConstraints {
43    pub platform: String,
44    pub browser_family: String,
45    pub policy: String,
46    pub max_sessions: u32,
47}
48
49#[derive(Debug, Deserialize)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51struct GlassCapabilityRequest {
52    #[serde(default = "default_protocol_version")]
53    protocol_version: u32,
54    #[serde(default)]
55    schemas: BTreeMap<String, Vec<u32>>,
56}
57
58/// A bounded failure returned before an unsupported contract is used.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct CapabilityNegotiationError {
61    pub field: String,
62    pub detail: String,
63}
64
65impl fmt::Display for CapabilityNegotiationError {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(formatter, "{}: {}", self.field, self.detail)
68    }
69}
70
71impl std::error::Error for CapabilityNegotiationError {}
72
73impl GlassCapabilityManifest {
74    /// Build the manifest for the current binary and policy.
75    pub fn for_policy(policy: &BrowserPolicy) -> Self {
76        Self::for_policy_in_mode(policy, false)
77    }
78
79    /// Build a manifest for an explicit runtime mode.
80    pub fn for_policy_in_mode(policy: &BrowserPolicy, local_daemon: bool) -> Self {
81        let raw_cdp = matches!(
82            policy.decide(PolicyCapability::RawCdp),
83            PolicyDecision::Allow
84        );
85        let persistent_profile = matches!(
86            policy.decide(PolicyCapability::PersistentProfile),
87            PolicyDecision::Allow
88        );
89        let mut capabilities = BTreeMap::from([
90            ("action", true),
91            ("semanticRegions", true),
92            ("observationDiffs", true),
93            ("intentResolution", true),
94            ("workflowRuntime", true),
95            ("workflowResume", true),
96            ("persistentKnowledge", persistent_profile),
97            ("workflowAuthoring", true),
98            ("reliabilityCertification", true),
99            ("rawCdp", raw_cdp),
100            ("localDaemon", local_daemon),
101            ("extensions", false),
102        ])
103        .into_iter()
104        .map(|(name, enabled)| (name.to_string(), enabled))
105        .collect::<BTreeMap<_, _>>();
106        capabilities.insert("mcpStdio".into(), true);
107        Self {
108            protocol_version: GLASS_PROTOCOL_VERSION,
109            glass_version: env!("CARGO_PKG_VERSION").into(),
110            schemas: supported_schemas(),
111            capabilities,
112            constraints: GlassCapabilityConstraints {
113                platform: platform_label().into(),
114                browser_family: "chromium".into(),
115                policy: policy_label(policy.preset()).into(),
116                max_sessions: 4,
117            },
118        }
119    }
120
121    /// Negotiate an optional Glass request against this manifest.
122    pub fn negotiate(&self, request: Option<&Value>) -> Result<Self, CapabilityNegotiationError> {
123        let Some(request) = request else {
124            return Ok(self.clone());
125        };
126        let request: GlassCapabilityRequest =
127            serde_json::from_value(request.clone()).map_err(|error| {
128                CapabilityNegotiationError {
129                    field: "glass".into(),
130                    detail: format!("invalid capability request: {error}"),
131                }
132            })?;
133        if request.protocol_version != self.protocol_version {
134            return Err(CapabilityNegotiationError {
135                field: "glass.protocolVersion".into(),
136                detail: format!(
137                    "unsupported protocol {}; expected {}",
138                    request.protocol_version, self.protocol_version
139                ),
140            });
141        }
142        for (name, requested_versions) in request.schemas {
143            let Some(supported_versions) = self.schemas.get(&name) else {
144                return Err(CapabilityNegotiationError {
145                    field: format!("glass.schemas.{name}"),
146                    detail: "unknown schema".into(),
147                });
148            };
149            if requested_versions.is_empty()
150                || !requested_versions
151                    .iter()
152                    .any(|version| supported_versions.contains(version))
153            {
154                return Err(CapabilityNegotiationError {
155                    field: format!("glass.schemas.{name}"),
156                    detail: format!(
157                        "requested versions do not intersect supported versions {supported_versions:?}"
158                    ),
159                });
160            }
161        }
162        Ok(self.clone())
163    }
164}
165
166fn supported_schemas() -> BTreeMap<String, Vec<u32>> {
167    BTreeMap::from([
168        ("protocol".into(), vec![GLASS_PROTOCOL_VERSION]),
169        ("action".into(), vec![1]),
170        (
171            "observation".into(),
172            vec![SEMANTIC_OBSERVATION_SCHEMA_VERSION],
173        ),
174        ("workflow".into(), vec![WORKFLOW_SCHEMA_VERSION]),
175        ("checkpoint".into(), vec![1]),
176        ("policy".into(), vec![POLICY_SCHEMA_VERSION]),
177        ("workflowCheckpoint".into(), vec![1]),
178        ("trace".into(), vec![1]),
179        ("intent".into(), vec![INTENT_RESOLUTION_SCHEMA_VERSION]),
180        ("knowledge".into(), vec![KNOWLEDGE_SCHEMA_VERSION]),
181        ("authoring".into(), vec![WORKFLOW_AUTHORING_SCHEMA_VERSION]),
182        (
183            "reliabilityScenario".into(),
184            vec![RELIABILITY_SCENARIO_SCHEMA_VERSION],
185        ),
186        (
187            "reliabilityFixture".into(),
188            vec![RELIABILITY_FIXTURE_SCHEMA_VERSION],
189        ),
190        (
191            "reliabilityReplay".into(),
192            vec![RELIABILITY_REPLAY_SCHEMA_VERSION],
193        ),
194    ])
195}
196
197fn default_protocol_version() -> u32 {
198    GLASS_PROTOCOL_VERSION
199}
200
201fn policy_label(policy: PolicyPreset) -> &'static str {
202    match policy {
203        PolicyPreset::Development => "development",
204        PolicyPreset::Ci => "ci",
205        PolicyPreset::Polite => "polite",
206        PolicyPreset::Hardened => "hardened",
207        PolicyPreset::UntrustedMcp => "untrusted-mcp",
208    }
209}
210
211fn platform_label() -> &'static str {
212    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
213    {
214        "linux-x86_64"
215    }
216    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
217    {
218        "linux-arm64"
219    }
220    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
221    {
222        "macos-x86_64"
223    }
224    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
225    {
226        "macos-aarch64"
227    }
228    #[cfg(not(any(
229        all(target_os = "linux", target_arch = "x86_64"),
230        all(target_os = "linux", target_arch = "aarch64"),
231        all(target_os = "macos", target_arch = "x86_64"),
232        all(target_os = "macos", target_arch = "aarch64")
233    )))]
234    {
235        "unsupported"
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn development_manifest() -> GlassCapabilityManifest {
244        let policy = BrowserPolicy::development(std::env::current_dir().unwrap()).unwrap();
245        GlassCapabilityManifest::for_policy(&policy)
246    }
247
248    #[test]
249    fn manifest_is_stable_and_lists_current_contracts() {
250        let manifest = development_manifest();
251
252        assert_eq!(manifest.protocol_version, 1);
253        assert_eq!(manifest.schemas["workflow"], vec![1]);
254        assert_eq!(manifest.schemas["reliabilityReplay"], vec![1]);
255        assert_eq!(manifest.constraints.max_sessions, 4);
256        assert!(manifest.capabilities["workflowResume"]);
257        assert!(!manifest.capabilities["localDaemon"]);
258    }
259
260    #[test]
261    fn negotiation_accepts_compatible_schema_requests() {
262        let manifest = development_manifest();
263        let request = serde_json::json!({
264            "protocolVersion": 1,
265            "schemas": {"action": [1], "workflow": [1]}
266        });
267
268        assert_eq!(manifest.negotiate(Some(&request)).unwrap(), manifest);
269    }
270
271    #[test]
272    fn negotiation_rejects_unknown_and_incompatible_requests() {
273        let manifest = development_manifest();
274        let unknown = serde_json::json!({
275            "protocolVersion": 1,
276            "schemas": {"future": [1]}
277        });
278        let incompatible = serde_json::json!({
279            "protocolVersion": 1,
280            "schemas": {"workflow": [99]}
281        });
282
283        assert!(manifest.negotiate(Some(&unknown)).is_err());
284        assert!(manifest.negotiate(Some(&incompatible)).is_err());
285    }
286}