diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-02 02:45:20 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-02 02:45:20 +0300 |
| commit | cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch) | |
| tree | 4d37c760099070f1a1488b61c7b7271875aafef8 /src/protocol/endpoint.rs | |
| parent | 18e69891dca486d669a584facd80644bb51f54a2 (diff) | |
feat: add stable client endpoint compatibility (#3509)
* feat: add stable client endpoint compatibility
* fix: keep portable-pty patch checks composable
* ci: run maintenance checks in pull requests
* fix: reject endpoint errors from terminal clients
* feat: keep compatible servers running across updates
Diffstat (limited to 'src/protocol/endpoint.rs')
| -rw-r--r-- | src/protocol/endpoint.rs | 275 |
1 files changed, 275 insertions, 0 deletions
diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs new file mode 100644 index 00000000..b2955ec8 --- /dev/null +++ b/src/protocol/endpoint.rs @@ -0,0 +1,275 @@ +//! 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<String>, + #[serde(default)] + pub surface_codecs: Vec<String>, + #[serde(default)] + pub input_codecs: Vec<String>, + #[serde(default)] + pub blob_codecs: Vec<String>, +} + +#[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<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<EndpointHandshakeError>, +} + +pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result<ServerMessage> { + 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<String>) -> 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<String>) -> 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::<serde_json::Value>(&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); + } +} |
