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 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
28pub use crate::protocol::GLASS_PROTOCOL_VERSION;
30
31#[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#[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#[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#[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#[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#[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 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#[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 pub fn for_policy(policy: &BrowserPolicy) -> Self {
180 Self::for_policy_in_mode(policy, false)
181 }
182
183 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 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 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 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}