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")]
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/// Phase in which a public operation stopped.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
119#[serde(rename_all = "camelCase")]
120pub enum ErrorPhase {
121    #[default]
122    Preflight,
123    Dispatch,
124    PostDispatch,
125    Verification,
126    Reconciliation,
127}
128
129/// Stable retry classification for agent recovery.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
131#[serde(rename_all = "camelCase")]
132pub enum RetryClassification {
133    SafeImmediate,
134    #[default]
135    SafeAfterReobserve,
136    SafeAfterReconcile,
137    UnsafeUntilReconciled,
138    RequiresUserDecision,
139    NotRetryable,
140    Unknown,
141}
142
143/// Bounded recovery guidance attached to every canonical failure.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct RetryGuidance {
147    pub classification: RetryClassification,
148    pub recommended_operation: String,
149}
150
151impl Default for RetryGuidance {
152    fn default() -> Self {
153        Self {
154            classification: RetryClassification::SafeAfterReobserve,
155            recommended_operation: "inspect_page".into(),
156        }
157    }
158}
159
160/// Structured failure that can be carried across transports without parsing text.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct GlassError {
164    pub code: String,
165    #[serde(default)]
166    pub phase: ErrorPhase,
167    pub message: String,
168    #[serde(default)]
169    pub mutation_possible: bool,
170    #[serde(default)]
171    pub retry: RetryGuidance,
172    /// Kept as a tolerated compatibility field for pre-0.2.2 clients.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub retryable: Option<bool>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub details: Option<Value>,
177}
178
179impl GlassError {
180    /// Validate bounded, non-empty diagnostic fields.
181    pub fn validate(&self) -> Result<(), ProtocolError> {
182        if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
183            return Err(ProtocolError::InvalidField(
184                "error code must be a bounded non-empty string".into(),
185            ));
186        }
187        if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
188            return Err(ProtocolError::InvalidField(
189                "error message must be a bounded non-empty string".into(),
190            ));
191        }
192        validate_identifier(
193            &self.retry.recommended_operation,
194            "retry.recommendedOperation",
195        )?;
196        Ok(())
197    }
198}
199
200/// Validation failure for the canonical protocol envelope.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ProtocolError {
203    UnsupportedVersion(u32),
204    InvalidField(String),
205}
206
207impl std::fmt::Display for ProtocolError {
208    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        match self {
210            Self::UnsupportedVersion(version) => {
211                write!(formatter, "unsupported Glass protocol version {version}")
212            }
213            Self::InvalidField(detail) => formatter.write_str(detail),
214        }
215    }
216}
217
218impl std::error::Error for ProtocolError {}
219
220fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
221    if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
222        return Err(ProtocolError::InvalidField(format!(
223            "{field} must be a bounded non-whitespace identifier"
224        )));
225    }
226    Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn request() -> GlassRequest {
234        GlassRequest {
235            protocol_version: GLASS_PROTOCOL_VERSION,
236            request_id: "request-1".into(),
237            correlation_id: Some("run-1".into()),
238            session_id: Some("session-1".into()),
239            mutation_lease: Some(MutationLeaseRef {
240                session_id: "session-1".into(),
241                token: "lease-1".into(),
242            }),
243            operation: "browser.observe".into(),
244            payload: serde_json::json!({"level": "interactive"}),
245            deadline_ms: Some(5_000),
246        }
247    }
248
249    #[test]
250    fn request_round_trips_and_validates() {
251        let request = request();
252        request.validate().unwrap();
253        let value = serde_json::to_value(&request).unwrap();
254        assert_eq!(value["protocolVersion"], 1);
255        assert_eq!(value["mutationLease"]["sessionId"], "session-1");
256        let decoded: GlassRequest = serde_json::from_value(value).unwrap();
257        assert_eq!(decoded, request);
258    }
259
260    #[test]
261    fn response_requires_exactly_one_outcome() {
262        let response = GlassResponse {
263            protocol_version: GLASS_PROTOCOL_VERSION,
264            request_id: "request-1".into(),
265            correlation_id: None,
266            ok: false,
267            result: None,
268            error: Some(GlassError {
269                code: "target.stale".into(),
270                phase: ErrorPhase::Preflight,
271                message: "a mutation lease is required".into(),
272                mutation_possible: false,
273                retry: RetryGuidance {
274                    classification: RetryClassification::SafeAfterReobserve,
275                    recommended_operation: "inspect_page".into(),
276                },
277                retryable: Some(true),
278                details: None,
279            }),
280        };
281        response.validate().unwrap();
282        let mut invalid = response.clone();
283        invalid.ok = true;
284        assert!(invalid.validate().is_err());
285    }
286
287    #[test]
288    fn bounds_and_unknown_fields_fail_closed() {
289        let mut request = request();
290        request.operation = "bad operation".into();
291        assert!(request.validate().is_err());
292        let unknown = serde_json::json!({
293            "protocolVersion": 1,
294            "requestId": "request-1",
295            "operation": "browser.observe",
296            "payload": {},
297            "future": true
298        });
299        assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
300    }
301
302    #[test]
303    fn additive_response_fields_are_tolerated() {
304        let response: GlassResponse = serde_json::from_value(serde_json::json!({
305            "protocolVersion": 1,
306            "requestId": "request-1",
307            "ok": false,
308            "error": {
309                "code": "target.stale",
310                "message": "stale",
311                "retryable": true,
312                "future": "ignored"
313            },
314            "future": true
315        }))
316        .unwrap();
317        assert_eq!(
318            response.error.unwrap().retry.classification,
319            RetryClassification::SafeAfterReobserve
320        );
321    }
322}