//! Stable endpoint compatibility contract for client-owned shells. //! //! The endpoint generation is intentionally independent from the private //! binary protocol used by same-install CLI, direct-terminal, and handoff //! paths. Generation 1 is the compatibility floor for Local, SSH, and Cloud //! shell endpoints and must remain available indefinitely unless retired for a //! security reason. New JSON fields must be optional or have serde defaults; //! new enum values need an `Unknown` fallback. Unknown named controls are //! optional and ignored unless negotiated as part of the core. use serde::{Deserialize, Serialize}; use super::{ClientShellSnapshot, ClientSurfaceSize, ServerMessage}; pub const ENDPOINT_PROTOCOL_GENERATION: u32 = 1; pub const ENDPOINT_HELLO_KIND: &str = "endpoint.hello.v1"; pub const ENDPOINT_WELCOME_KIND: &str = "endpoint.welcome.v1"; pub const SNAPSHOT_CODEC_V1: &str = "shell.snapshot.v1"; pub const ENDPOINT_SNAPSHOT_KIND: &str = SNAPSHOT_CODEC_V1; pub const SURFACE_CODEC_V1: &str = "shell.surface.v1"; pub const INPUT_CODEC_V1: &str = "shell.input.semantic.v1"; pub const BLOB_CODEC_V1: &str = "shell.blob.v1"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointClientHello { pub generation: u32, pub cell_width_px: u32, pub cell_height_px: u32, pub surface_size: ClientSurfaceSize, pub pixel_mouse: bool, pub direct_graphics: bool, pub endpoint_keybindings: bool, pub mouse_capture: bool, #[serde(default)] pub snapshot_codecs: Vec, #[serde(default)] pub surface_codecs: Vec, #[serde(default)] pub input_codecs: Vec, #[serde(default)] pub blob_codecs: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointHandshakeError { pub code: String, pub message: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointServerWelcome { pub generation: u32, pub server_version: String, pub snapshot_codec: String, pub surface_codec: String, pub input_codec: String, pub blob_codec: String, #[serde(default)] pub methods: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result { Ok(ServerMessage::EndpointControl { kind: ENDPOINT_SNAPSHOT_KIND.into(), data: serde_json::to_string(snapshot)?, }) } impl EndpointClientHello { pub fn supports_required_codecs(&self) -> bool { self.snapshot_codecs .iter() .any(|codec| codec == SNAPSHOT_CODEC_V1) && self .surface_codecs .iter() .any(|codec| codec == SURFACE_CODEC_V1) && self .input_codecs .iter() .any(|codec| codec == INPUT_CODEC_V1) && self.blob_codecs.iter().any(|codec| codec == BLOB_CODEC_V1) } } impl EndpointServerWelcome { pub fn compatible(methods: Vec) -> Self { Self { generation: ENDPOINT_PROTOCOL_GENERATION, server_version: crate::build_info::version(), snapshot_codec: SNAPSHOT_CODEC_V1.into(), surface_codec: SURFACE_CODEC_V1.into(), input_codec: INPUT_CODEC_V1.into(), blob_codec: BLOB_CODEC_V1.into(), methods, error: None, } } pub fn incompatible(code: &str, message: impl Into) -> Self { Self { generation: ENDPOINT_PROTOCOL_GENERATION, server_version: crate::build_info::version(), snapshot_codec: SNAPSHOT_CODEC_V1.into(), surface_codec: SURFACE_CODEC_V1.into(), input_codec: INPUT_CODEC_V1.into(), blob_codec: BLOB_CODEC_V1.into(), methods: Vec::new(), error: Some(EndpointHandshakeError { code: code.into(), message: message.into(), }), } } } #[cfg(test)] mod tests { use super::*; fn hello() -> EndpointClientHello { EndpointClientHello { generation: ENDPOINT_PROTOCOL_GENERATION, cell_width_px: 8, cell_height_px: 16, surface_size: ClientSurfaceSize { cols: 80, rows: 24 }, pixel_mouse: true, direct_graphics: false, endpoint_keybindings: false, mouse_capture: true, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], blob_codecs: vec![BLOB_CODEC_V1.into()], } } fn snapshot() -> ClientShellSnapshot { ClientShellSnapshot { boot_id: "boot".into(), revision: 1, config_diagnostic: None, product_announcement: None, update_available: None, update_install_command: "herdr update".into(), server_keybindings_toml: None, latest_release_notes_available: false, integration_updates_available: false, worktree_directory: String::new(), release_notes: None, focused_workspace_id: None, focused_tab_id: None, focused_pane_id: None, tab_bar_right: Vec::new(), tab_bar_right_separator: String::new(), agent_view_label: None, agent_order: Vec::new(), workspaces: Vec::new(), tabs: Vec::new(), panes: Vec::new(), agents: Vec::new(), commands: Vec::new(), } } #[test] fn hello_ignores_future_named_fields() { let mut value = serde_json::to_value(hello()).unwrap(); value["future_feature"] = serde_json::json!({"enabled": true}); let decoded: EndpointClientHello = serde_json::from_value(value).unwrap(); assert_eq!(decoded, hello()); } #[test] fn frozen_generation_one_handshake_decodes() { let hello: EndpointClientHello = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/endpoint-hello-v1.json" ))) .unwrap(); assert!(hello.supports_required_codecs()); let welcome: EndpointServerWelcome = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/endpoint-welcome-v1.json" ))) .unwrap(); assert_eq!(welcome.generation, ENDPOINT_PROTOCOL_GENERATION); assert_eq!(welcome.snapshot_codec, SNAPSHOT_CODEC_V1); assert_eq!(welcome.surface_codec, SURFACE_CODEC_V1); assert_eq!(welcome.input_codec, INPUT_CODEC_V1); assert_eq!(welcome.blob_codec, BLOB_CODEC_V1); } #[test] fn frozen_generation_one_snapshot_decodes() { let snapshot: ClientShellSnapshot = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/endpoint-snapshot-v1.json" ))) .unwrap(); assert_eq!(snapshot.boot_id, "boot-v1"); assert_eq!( snapshot.workspaces[0].agent_status, crate::api::schema::AgentStatus::Unknown ); } #[test] fn snapshot_message_uses_named_json_control() { let snapshot = snapshot(); let ServerMessage::EndpointControl { kind, data } = snapshot_message(&snapshot).unwrap() else { panic!("snapshot should use endpoint control"); }; assert_eq!(kind, ENDPOINT_SNAPSHOT_KIND); let decoded: ClientShellSnapshot = serde_json::from_str(&data).unwrap(); assert_eq!(decoded, snapshot); } #[test] fn snapshot_json_tolerates_future_fields_and_command_actions() { let mut snapshot = match snapshot_message(&snapshot()).unwrap() { ServerMessage::EndpointControl { data, .. } => { serde_json::from_str::(&data).unwrap() } _ => unreachable!(), }; snapshot["future_projection"] = serde_json::json!({"enabled": true}); snapshot["commands"] = serde_json::json!([{ "command_id": "future", "binding_label": "x", "binding_labels": ["x"], "action": "FutureAction", "description": null }]); let decoded: ClientShellSnapshot = serde_json::from_value(snapshot).unwrap(); assert_eq!( decoded.commands[0].action, crate::protocol::ClientShellCommandAction::Unknown ); } #[test] fn required_codecs_are_explicit() { let mut value = hello(); assert!(value.supports_required_codecs()); value.snapshot_codecs.clear(); assert!(!value.supports_required_codecs()); let mut value = hello(); value.surface_codecs.clear(); assert!(!value.supports_required_codecs()); let mut value = hello(); value.input_codecs.clear(); assert!(!value.supports_required_codecs()); let mut value = hello(); value.blob_codecs.clear(); assert!(!value.supports_required_codecs()); } #[test] fn welcome_ignores_future_named_fields() { let welcome = EndpointServerWelcome::compatible(vec!["pane.close".into()]); let mut value = serde_json::to_value(&welcome).unwrap(); value["future_service"] = serde_json::json!("v2"); let decoded: EndpointServerWelcome = serde_json::from_value(value).unwrap(); assert_eq!(decoded, welcome); } }