Skip to main content

glass/
protocol.rs

1//! Transport-neutral Glass request and response envelopes.
2//!
3//! MCP keeps its JSON-RPC framing, but daemon clients and embedded callers use
4//! these envelopes for the operation payload. The envelope is intentionally
5//! small: transport-specific framing, streaming, and authentication remain
6//! outside this contract.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// Version of the canonical Glass operation envelope.
12pub const GLASS_PROTOCOL_VERSION: u32 = 1;
13const MAX_ID_BYTES: usize = 128;
14const MAX_OPERATION_BYTES: usize = 96;
15const MAX_ERROR_CODE_BYTES: usize = 64;
16const MAX_MESSAGE_BYTES: usize = 512;
17const MAX_DEADLINE_MS: u64 = 15 * 60 * 1_000;
18
19/// A request-independent mutation lease reference.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct MutationLeaseRef {
23    pub session_id: String,
24    pub token: String,
25}
26
27/// Canonical operation request shared by supported transports.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct GlassRequest {
31    pub protocol_version: u32,
32    pub request_id: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub correlation_id: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub session_id: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub mutation_lease: Option<MutationLeaseRef>,
39    pub operation: String,
40    pub payload: Value,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub deadline_ms: Option<u64>,
43}
44
45impl GlassRequest {
46    /// Validate protocol version, identifiers, operation bounds, and deadline.
47    pub fn validate(&self) -> Result<(), ProtocolError> {
48        if self.protocol_version != GLASS_PROTOCOL_VERSION {
49            return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
50        }
51        validate_identifier(&self.request_id, "requestId")?;
52        if let Some(correlation_id) = &self.correlation_id {
53            validate_identifier(correlation_id, "correlationId")?;
54        }
55        if let Some(session_id) = &self.session_id {
56            validate_identifier(session_id, "sessionId")?;
57        }
58        if let Some(lease) = &self.mutation_lease {
59            validate_identifier(&lease.session_id, "mutationLease.sessionId")?;
60            validate_identifier(&lease.token, "mutationLease.token")?;
61        }
62        if self.operation.is_empty() || self.operation.len() > MAX_OPERATION_BYTES {
63            return Err(ProtocolError::InvalidField(
64                "operation must be a bounded non-empty string".into(),
65            ));
66        }
67        if self.operation.chars().any(char::is_whitespace) {
68            return Err(ProtocolError::InvalidField(
69                "operation must not contain whitespace".into(),
70            ));
71        }
72        if let Some(deadline_ms) = self.deadline_ms
73            && !(1..=MAX_DEADLINE_MS).contains(&deadline_ms)
74        {
75            return Err(ProtocolError::InvalidField(format!(
76                "deadlineMs must be 1..={MAX_DEADLINE_MS}"
77            )));
78        }
79        Ok(())
80    }
81}
82
83/// Canonical operation response shared by supported transports.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86pub struct GlassResponse {
87    pub protocol_version: u32,
88    pub request_id: String,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub correlation_id: Option<String>,
91    pub ok: bool,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub result: Option<Value>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub error: Option<GlassError>,
96}
97
98impl GlassResponse {
99    /// Validate envelope identity and the mutually exclusive result/error form.
100    pub fn validate(&self) -> Result<(), ProtocolError> {
101        if self.protocol_version != GLASS_PROTOCOL_VERSION {
102            return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
103        }
104        validate_identifier(&self.request_id, "requestId")?;
105        if let Some(correlation_id) = &self.correlation_id {
106            validate_identifier(correlation_id, "correlationId")?;
107        }
108        match (self.ok, self.result.is_some(), self.error.is_some()) {
109            (true, true, false) | (false, false, true) => Ok(()),
110            _ => Err(ProtocolError::InvalidField(
111                "ok responses require result and error responses require error".into(),
112            )),
113        }
114    }
115}
116
117/// Structured failure that can be carried across transports without parsing text.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase", deny_unknown_fields)]
120pub struct GlassError {
121    pub code: String,
122    pub message: String,
123    pub retryable: bool,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub details: Option<Value>,
126}
127
128impl GlassError {
129    /// Validate bounded, non-empty diagnostic fields.
130    pub fn validate(&self) -> Result<(), ProtocolError> {
131        if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
132            return Err(ProtocolError::InvalidField(
133                "error code must be a bounded non-empty string".into(),
134            ));
135        }
136        if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
137            return Err(ProtocolError::InvalidField(
138                "error message must be a bounded non-empty string".into(),
139            ));
140        }
141        Ok(())
142    }
143}
144
145/// Validation failure for the canonical protocol envelope.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ProtocolError {
148    UnsupportedVersion(u32),
149    InvalidField(String),
150}
151
152impl std::fmt::Display for ProtocolError {
153    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Self::UnsupportedVersion(version) => {
156                write!(formatter, "unsupported Glass protocol version {version}")
157            }
158            Self::InvalidField(detail) => formatter.write_str(detail),
159        }
160    }
161}
162
163impl std::error::Error for ProtocolError {}
164
165fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
166    if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
167        return Err(ProtocolError::InvalidField(format!(
168            "{field} must be a bounded non-whitespace identifier"
169        )));
170    }
171    Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn request() -> GlassRequest {
179        GlassRequest {
180            protocol_version: GLASS_PROTOCOL_VERSION,
181            request_id: "request-1".into(),
182            correlation_id: Some("run-1".into()),
183            session_id: Some("session-1".into()),
184            mutation_lease: Some(MutationLeaseRef {
185                session_id: "session-1".into(),
186                token: "lease-1".into(),
187            }),
188            operation: "browser.observe".into(),
189            payload: serde_json::json!({"level": "interactive"}),
190            deadline_ms: Some(5_000),
191        }
192    }
193
194    #[test]
195    fn request_round_trips_and_validates() {
196        let request = request();
197        request.validate().unwrap();
198        let value = serde_json::to_value(&request).unwrap();
199        assert_eq!(value["protocolVersion"], 1);
200        assert_eq!(value["mutationLease"]["sessionId"], "session-1");
201        let decoded: GlassRequest = serde_json::from_value(value).unwrap();
202        assert_eq!(decoded, request);
203    }
204
205    #[test]
206    fn response_requires_exactly_one_outcome() {
207        let response = GlassResponse {
208            protocol_version: GLASS_PROTOCOL_VERSION,
209            request_id: "request-1".into(),
210            correlation_id: None,
211            ok: false,
212            result: None,
213            error: Some(GlassError {
214                code: "leaseRequired".into(),
215                message: "a mutation lease is required".into(),
216                retryable: true,
217                details: None,
218            }),
219        };
220        response.validate().unwrap();
221        let mut invalid = response.clone();
222        invalid.ok = true;
223        assert!(invalid.validate().is_err());
224    }
225
226    #[test]
227    fn bounds_and_unknown_fields_fail_closed() {
228        let mut request = request();
229        request.operation = "bad operation".into();
230        assert!(request.validate().is_err());
231        let unknown = serde_json::json!({
232            "protocolVersion": 1,
233            "requestId": "request-1",
234            "operation": "browser.observe",
235            "payload": {},
236            "future": true
237        });
238        assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
239    }
240}