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/// Canonical transport operation for browser-free Task Protocol compilation.
20pub const TASK_COMPILE_OPERATION: &str = "task.compile";
21
22/// Typed payload carried by a `task.compile` request.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct TaskCompilePayload {
26    pub task: crate::task_protocol::GlassTask,
27}
28
29impl TaskCompilePayload {
30    /// Validate the authored task before compiler dispatch.
31    pub fn validate(&self) -> Result<(), ProtocolError> {
32        self.task
33            .validate()
34            .map_err(|error| ProtocolError::InvalidField(error.to_string()))
35    }
36}
37
38/// Typed successful result for a `task.compile` operation.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct TaskCompileResult {
42    pub plan: crate::task_compiler::TaskExecutionPlan,
43}
44
45/// Typed successful result for browser-free Task Protocol validation.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase", deny_unknown_fields)]
48pub struct TaskValidationResult {
49    pub valid: bool,
50    pub schema_version: u32,
51    pub task: crate::task_protocol::TaskKind,
52}
53
54impl TaskCompileResult {
55    /// Validate the embedded deterministic execution plan.
56    pub fn validate(&self) -> Result<(), ProtocolError> {
57        self.plan
58            .validate()
59            .map_err(|error| ProtocolError::InvalidField(error.to_string()))
60    }
61}
62
63/// A request-independent mutation lease reference.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct MutationLeaseRef {
67    pub session_id: String,
68    pub token: String,
69}
70
71/// Canonical operation request shared by supported transports.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct GlassRequest {
75    pub protocol_version: u32,
76    pub request_id: String,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub correlation_id: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub session_id: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub mutation_lease: Option<MutationLeaseRef>,
83    pub operation: String,
84    pub payload: Value,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub deadline_ms: Option<u64>,
87}
88
89impl GlassRequest {
90    /// Validate protocol version, identifiers, operation bounds, and deadline.
91    pub fn validate(&self) -> Result<(), ProtocolError> {
92        if self.protocol_version != GLASS_PROTOCOL_VERSION {
93            return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
94        }
95        validate_identifier(&self.request_id, "requestId")?;
96        if let Some(correlation_id) = &self.correlation_id {
97            validate_identifier(correlation_id, "correlationId")?;
98        }
99        if let Some(session_id) = &self.session_id {
100            validate_identifier(session_id, "sessionId")?;
101        }
102        if let Some(lease) = &self.mutation_lease {
103            validate_identifier(&lease.session_id, "mutationLease.sessionId")?;
104            validate_identifier(&lease.token, "mutationLease.token")?;
105        }
106        if self.operation.is_empty() || self.operation.len() > MAX_OPERATION_BYTES {
107            return Err(ProtocolError::InvalidField(
108                "operation must be a bounded non-empty string".into(),
109            ));
110        }
111        if self.operation.chars().any(char::is_whitespace) {
112            return Err(ProtocolError::InvalidField(
113                "operation must not contain whitespace".into(),
114            ));
115        }
116        if let Some(deadline_ms) = self.deadline_ms
117            && !(1..=MAX_DEADLINE_MS).contains(&deadline_ms)
118        {
119            return Err(ProtocolError::InvalidField(format!(
120                "deadlineMs must be 1..={MAX_DEADLINE_MS}"
121            )));
122        }
123        Ok(())
124    }
125
126    /// Decode and validate a typed `task.compile` payload.
127    pub fn decode_task_compile(&self) -> Result<TaskCompilePayload, ProtocolError> {
128        self.validate()?;
129        if self.operation != TASK_COMPILE_OPERATION {
130            return Err(ProtocolError::InvalidField(format!(
131                "expected operation {TASK_COMPILE_OPERATION}"
132            )));
133        }
134        let payload: TaskCompilePayload =
135            serde_json::from_value(self.payload.clone()).map_err(|error| {
136                ProtocolError::InvalidField(format!("task.compile payload: {error}"))
137            })?;
138        payload.validate()?;
139        Ok(payload)
140    }
141}
142
143/// Decode and compile a `task.compile` request without browser access.
144pub fn compile_task_request(
145    request: &GlassRequest,
146) -> Result<crate::task_compiler::TaskExecutionPlan, ProtocolError> {
147    let payload = request.decode_task_compile()?;
148    crate::task_compiler::compile_task(&payload.task)
149        .map_err(|error| ProtocolError::InvalidField(error.to_string()))
150}
151
152/// Decode and compile a `task.compile` request into a typed response payload.
153pub fn compile_task_result(request: &GlassRequest) -> Result<TaskCompileResult, ProtocolError> {
154    Ok(TaskCompileResult {
155        plan: compile_task_request(request)?,
156    })
157}
158
159/// Canonical operation response shared by supported transports.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct GlassResponse {
163    pub protocol_version: u32,
164    pub request_id: String,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub correlation_id: Option<String>,
167    pub ok: bool,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub result: Option<Value>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub error: Option<GlassError>,
172}
173
174impl GlassResponse {
175    /// Validate envelope identity and the mutually exclusive result/error form.
176    pub fn validate(&self) -> Result<(), ProtocolError> {
177        if self.protocol_version != GLASS_PROTOCOL_VERSION {
178            return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
179        }
180        validate_identifier(&self.request_id, "requestId")?;
181        if let Some(correlation_id) = &self.correlation_id {
182            validate_identifier(correlation_id, "correlationId")?;
183        }
184        match (self.ok, self.result.is_some(), self.error.is_some()) {
185            (true, true, false) | (false, false, true) => Ok(()),
186            _ => Err(ProtocolError::InvalidField(
187                "ok responses require result and error responses require error".into(),
188            )),
189        }
190    }
191
192    /// Decode and validate a successful typed `task.compile` result.
193    pub fn decode_task_compile_result(&self) -> Result<TaskCompileResult, ProtocolError> {
194        self.validate()?;
195        if !self.ok {
196            return Err(ProtocolError::InvalidField(
197                "task.compile result requires a successful response".into(),
198            ));
199        }
200        let value = self
201            .result
202            .clone()
203            .ok_or_else(|| ProtocolError::InvalidField("task.compile result is missing".into()))?;
204        let result: TaskCompileResult = serde_json::from_value(value).map_err(|error| {
205            ProtocolError::InvalidField(format!("task.compile result: {error}"))
206        })?;
207        result.validate()?;
208        Ok(result)
209    }
210}
211
212/// Phase in which a public operation stopped.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
214#[serde(rename_all = "camelCase")]
215pub enum ErrorPhase {
216    #[default]
217    Preflight,
218    Dispatch,
219    PostDispatch,
220    Verification,
221    Reconciliation,
222}
223
224/// Stable retry classification for agent recovery.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
226#[serde(rename_all = "camelCase")]
227pub enum RetryClassification {
228    SafeImmediate,
229    #[default]
230    SafeAfterReobserve,
231    SafeAfterReconcile,
232    UnsafeUntilReconciled,
233    RequiresUserDecision,
234    NotRetryable,
235    Unknown,
236}
237
238/// Bounded recovery guidance attached to every canonical failure.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct RetryGuidance {
242    pub classification: RetryClassification,
243    pub recommended_operation: String,
244}
245
246impl Default for RetryGuidance {
247    fn default() -> Self {
248        Self {
249            classification: RetryClassification::SafeAfterReobserve,
250            recommended_operation: "inspect_page".into(),
251        }
252    }
253}
254
255/// Structured failure that can be carried across transports without parsing text.
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct GlassError {
259    pub code: String,
260    #[serde(default)]
261    pub phase: ErrorPhase,
262    pub message: String,
263    #[serde(default)]
264    pub mutation_possible: bool,
265    #[serde(default)]
266    pub retry: RetryGuidance,
267    /// Kept as a tolerated compatibility field for pre-0.2.2 clients.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub retryable: Option<bool>,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub details: Option<Value>,
272}
273
274impl GlassError {
275    /// Validate bounded, non-empty diagnostic fields.
276    pub fn validate(&self) -> Result<(), ProtocolError> {
277        if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
278            return Err(ProtocolError::InvalidField(
279                "error code must be a bounded non-empty string".into(),
280            ));
281        }
282        if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
283            return Err(ProtocolError::InvalidField(
284                "error message must be a bounded non-empty string".into(),
285            ));
286        }
287        validate_identifier(
288            &self.retry.recommended_operation,
289            "retry.recommendedOperation",
290        )?;
291        Ok(())
292    }
293}
294
295/// Validation failure for the canonical protocol envelope.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum ProtocolError {
298    UnsupportedVersion(u32),
299    InvalidField(String),
300}
301
302impl std::fmt::Display for ProtocolError {
303    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        match self {
305            Self::UnsupportedVersion(version) => {
306                write!(formatter, "unsupported Glass protocol version {version}")
307            }
308            Self::InvalidField(detail) => formatter.write_str(detail),
309        }
310    }
311}
312
313impl std::error::Error for ProtocolError {}
314
315fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
316    if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
317        return Err(ProtocolError::InvalidField(format!(
318            "{field} must be a bounded non-whitespace identifier"
319        )));
320    }
321    Ok(())
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn request() -> GlassRequest {
329        GlassRequest {
330            protocol_version: GLASS_PROTOCOL_VERSION,
331            request_id: "request-1".into(),
332            correlation_id: Some("run-1".into()),
333            session_id: Some("session-1".into()),
334            mutation_lease: Some(MutationLeaseRef {
335                session_id: "session-1".into(),
336                token: "lease-1".into(),
337            }),
338            operation: "browser.observe".into(),
339            payload: serde_json::json!({"level": "interactive"}),
340            deadline_ms: Some(5_000),
341        }
342    }
343
344    #[test]
345    fn request_round_trips_and_validates() {
346        let request = request();
347        request.validate().unwrap();
348        let value = serde_json::to_value(&request).unwrap();
349        assert_eq!(value["protocolVersion"], 1);
350        assert_eq!(value["mutationLease"]["sessionId"], "session-1");
351        let decoded: GlassRequest = serde_json::from_value(value).unwrap();
352        assert_eq!(decoded, request);
353    }
354
355    #[test]
356    fn response_requires_exactly_one_outcome() {
357        let response = GlassResponse {
358            protocol_version: GLASS_PROTOCOL_VERSION,
359            request_id: "request-1".into(),
360            correlation_id: None,
361            ok: false,
362            result: None,
363            error: Some(GlassError {
364                code: "target.stale".into(),
365                phase: ErrorPhase::Preflight,
366                message: "a mutation lease is required".into(),
367                mutation_possible: false,
368                retry: RetryGuidance {
369                    classification: RetryClassification::SafeAfterReobserve,
370                    recommended_operation: "inspect_page".into(),
371                },
372                retryable: Some(true),
373                details: None,
374            }),
375        };
376        response.validate().unwrap();
377        let mut invalid = response.clone();
378        invalid.ok = true;
379        assert!(invalid.validate().is_err());
380    }
381
382    #[test]
383    fn bounds_and_unknown_fields_fail_closed() {
384        let mut request = request();
385        request.operation = "bad operation".into();
386        assert!(request.validate().is_err());
387        let unknown = serde_json::json!({
388            "protocolVersion": 1,
389            "requestId": "request-1",
390            "operation": "browser.observe",
391            "payload": {},
392            "future": true
393        });
394        assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
395    }
396
397    #[test]
398    fn task_compile_boundary_decodes_and_compiles_without_browser_state() {
399        let task = serde_json::json!({
400            "schemaVersion": 1,
401            "task": "region.extract",
402            "scope": {"regionName": "Checkout"},
403            "limits": {"maxActions": 8, "timeoutMs": 5000, "maxItems": 32},
404            "risk": "readOnly"
405        });
406        let request = GlassRequest {
407            protocol_version: GLASS_PROTOCOL_VERSION,
408            request_id: "compile-1".into(),
409            correlation_id: None,
410            session_id: None,
411            mutation_lease: None,
412            operation: TASK_COMPILE_OPERATION.into(),
413            payload: serde_json::json!({"task": task}),
414            deadline_ms: None,
415        };
416        let plan = compile_task_request(&request).unwrap();
417        assert_eq!(plan.task, crate::task_protocol::TaskKind::RegionExtract);
418        assert_eq!(plan.scope.region_name.as_deref(), Some("Checkout"));
419        assert_eq!(plan.limits.max_actions, 8);
420        assert_eq!(
421            plan.revision,
422            crate::task_protocol::TaskRevisionPolicy::Exact
423        );
424
425        let mut wrong_operation = request.clone();
426        wrong_operation.operation = "browser.observe".into();
427        assert!(wrong_operation.decode_task_compile().is_err());
428
429        let mut unknown = request.clone();
430        unknown.payload["futureField"] = true.into();
431        assert!(unknown.decode_task_compile().is_err());
432
433        let mut invalid = request;
434        invalid.payload["task"]["task"] = "form.fill".into();
435        assert!(compile_task_request(&invalid).is_err());
436    }
437
438    #[test]
439    fn task_compile_result_round_trips_through_success_response() {
440        let request = GlassRequest {
441            protocol_version: GLASS_PROTOCOL_VERSION,
442            request_id: "compile-2".into(),
443            correlation_id: None,
444            session_id: None,
445            mutation_lease: None,
446            operation: TASK_COMPILE_OPERATION.into(),
447            payload: serde_json::json!({
448                "task": {
449                    "schemaVersion": 1,
450                    "task": "field.read",
451                    "scope": {"entityKind": "field", "entityName": "Email"},
452                    "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 1},
453                    "risk": "readOnly"
454                }
455            }),
456            deadline_ms: None,
457        };
458        let result = compile_task_result(&request).unwrap();
459        let response = GlassResponse {
460            protocol_version: GLASS_PROTOCOL_VERSION,
461            request_id: request.request_id.clone(),
462            correlation_id: None,
463            ok: true,
464            result: Some(serde_json::to_value(&result).unwrap()),
465            error: None,
466        };
467        assert_eq!(response.decode_task_compile_result().unwrap(), result);
468
469        let mut unknown = response.clone();
470        unknown.result.as_mut().unwrap()["futureField"] = true.into();
471        assert!(unknown.decode_task_compile_result().is_err());
472
473        let mut failure = response;
474        failure.ok = false;
475        failure.result = None;
476        failure.error = Some(GlassError {
477            code: "task.invalid".into(),
478            phase: ErrorPhase::Preflight,
479            message: "invalid task".into(),
480            mutation_possible: false,
481            retry: RetryGuidance::default(),
482            retryable: None,
483            details: None,
484        });
485        assert!(failure.decode_task_compile_result().is_err());
486    }
487
488    #[test]
489    fn additive_response_fields_are_tolerated() {
490        let response: GlassResponse = serde_json::from_value(serde_json::json!({
491            "protocolVersion": 1,
492            "requestId": "request-1",
493            "ok": false,
494            "error": {
495                "code": "target.stale",
496                "message": "stale",
497                "retryable": true,
498                "future": "ignored"
499            },
500            "future": true
501        }))
502        .unwrap();
503        assert_eq!(
504            response.error.unwrap().retry.classification,
505            RetryClassification::SafeAfterReobserve
506        );
507    }
508}