aboutsummaryrefslogtreecommitdiff
path: root/src/protocol/endpoint.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-02 02:45:20 +0300
committerGitHub <noreply@github.com>2026-09-02 02:45:20 +0300
commitcc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch)
tree4d37c760099070f1a1488b61c7b7271875aafef8 /src/protocol/endpoint.rs
parent18e69891dca486d669a584facd80644bb51f54a2 (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.rs275
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);
+ }
+}