1use 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
26pub use crate::protocol::GLASS_PROTOCOL_VERSION;
28
29#[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#[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#[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#[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#[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#[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 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#[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 pub fn for_policy(policy: &BrowserPolicy) -> Self {
178 Self::for_policy_in_mode(policy, false)
179 }
180
181 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 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 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 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}