aboutsummaryrefslogtreecommitdiff
path: root/src/protocol/wire.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/protocol/wire.rs')
-rw-r--r--src/protocol/wire.rs337
1 files changed, 329 insertions, 8 deletions
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index be084aa6..cbca829a 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -1,7 +1,11 @@
//! Wire protocol for herdr server/client communication.
//!
//! Defines the message types, framing, version negotiation, and safety
-//! constraints for the binary protocol over Unix domain sockets.
+//! constraints for the binary protocol over local sockets.
+//!
+//! `PROTOCOL_VERSION` still guards same-install direct-terminal and internal
+//! operations. Client-owned shells negotiate the independent stable endpoint
+//! contract in [`super::endpoint`].
use std::collections::HashMap;
use std::io::{self, Read, Write};
@@ -449,6 +453,9 @@ impl ClientInputEvent {
}
/// Messages sent from the client to the server over the client protocol socket.
+///
+/// Variant order is frozen for endpoint generation 1. Add compatible endpoint
+/// behavior through `EndpointControl` or advertised API methods, not new enum variants.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMessage {
/// Direct terminal handshake: announces protocol version and terminal dimensions.
@@ -597,6 +604,12 @@ pub enum ClientMessage {
/// Update this client's shell mouse-capture preference after config reload.
ClientShellMouseCapture { enabled: bool },
+
+ /// Extensible named control message for the stable client-owned endpoint protocol.
+ ///
+ /// This variant is append-only. Its bincode tag and two-string payload are part
+ /// of endpoint generation 1 and must not change.
+ EndpointControl { kind: String, data: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -860,7 +873,26 @@ impl FrameData {
}
}
-/// Initial resource projection used by the experimental client-owned shell.
+fn deserialize_client_shell_agent_status<'de, D>(
+ deserializer: D,
+) -> Result<crate::api::schema::AgentStatus, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ if !deserializer.is_human_readable() {
+ return crate::api::schema::AgentStatus::deserialize(deserializer);
+ }
+ let value = String::deserialize(deserializer)?;
+ Ok(match value.as_str() {
+ "idle" => crate::api::schema::AgentStatus::Idle,
+ "working" => crate::api::schema::AgentStatus::Working,
+ "blocked" => crate::api::schema::AgentStatus::Blocked,
+ "done" => crate::api::schema::AgentStatus::Done,
+ _ => crate::api::schema::AgentStatus::Unknown,
+ })
+}
+
+/// Initial resource projection used by the stable client-owned shell.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientShellSnapshot {
/// Changes whenever the endpoint process restarts.
@@ -921,6 +953,9 @@ pub enum ClientShellCommandAction {
Pane,
Popup,
PluginAction,
+ /// A future endpoint action kind that this client cannot execute.
+ #[serde(other)]
+ Unknown,
}
impl From<crate::config::CustomCommandAction> for ClientShellCommandAction {
@@ -934,13 +969,16 @@ impl From<crate::config::CustomCommandAction> for ClientShellCommandAction {
}
}
-impl From<ClientShellCommandAction> for crate::config::CustomCommandAction {
- fn from(action: ClientShellCommandAction) -> Self {
+impl TryFrom<ClientShellCommandAction> for crate::config::CustomCommandAction {
+ type Error = ();
+
+ fn try_from(action: ClientShellCommandAction) -> Result<Self, Self::Error> {
match action {
- ClientShellCommandAction::Shell => Self::Shell,
- ClientShellCommandAction::Pane => Self::Pane,
- ClientShellCommandAction::Popup => Self::Popup,
- ClientShellCommandAction::PluginAction => Self::PluginAction,
+ ClientShellCommandAction::Shell => Ok(Self::Shell),
+ ClientShellCommandAction::Pane => Ok(Self::Pane),
+ ClientShellCommandAction::Popup => Ok(Self::Popup),
+ ClientShellCommandAction::PluginAction => Ok(Self::PluginAction),
+ ClientShellCommandAction::Unknown => Err(()),
}
}
}
@@ -973,6 +1011,7 @@ pub struct ClientShellWorkspace {
pub tokens: Vec<(String, String)>,
pub worktree: Option<ClientShellWorktree>,
pub focused: bool,
+ #[serde(deserialize_with = "deserialize_client_shell_agent_status")]
pub agent_status: crate::api::schema::AgentStatus,
}
@@ -992,6 +1031,7 @@ pub struct ClientShellTab {
pub custom_label: bool,
pub zoomed: bool,
pub focused: bool,
+ #[serde(deserialize_with = "deserialize_client_shell_agent_status")]
pub agent_status: crate::api::schema::AgentStatus,
}
@@ -1018,6 +1058,7 @@ pub struct ClientShellAgent {
pub title: Option<String>,
pub terminal_title: Option<String>,
pub terminal_title_stripped: Option<String>,
+ #[serde(deserialize_with = "deserialize_client_shell_agent_status")]
pub agent_status: crate::api::schema::AgentStatus,
pub state_change_seq: u64,
pub state_labels: Vec<(String, String)>,
@@ -1271,6 +1312,9 @@ pub struct SemanticNotification {
}
/// Messages sent from the server to the client over the client protocol socket.
+///
+/// Variant order is frozen for endpoint generation 1. Add compatible endpoint
+/// behavior through `EndpointControl`, and ignore unrecognized named controls.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServerMessage {
/// Handshake response: server acknowledges (or rejects) the client.
@@ -1386,6 +1430,12 @@ pub enum ServerMessage {
/// Incremental terminal-cell update for a previously committed pane surface.
PaneSurfacePatch(PaneSurfacePatch),
+
+ /// Extensible named control message for the stable client-owned endpoint protocol.
+ ///
+ /// This variant is append-only. Its bincode tag and two-string payload are part
+ /// of endpoint generation 1 and must not change.
+ EndpointControl { kind: String, data: String },
}
// ---------------------------------------------------------------------------
@@ -1659,6 +1709,17 @@ pub fn check_client_version(client_version: u32) -> VersionCheck {
mod tests {
use super::*;
use ratatui::style::{Color, Modifier};
+ use sha2::{Digest, Sha256};
+
+ fn encoded_sha256(value: &impl Serialize) -> String {
+ let encoded = bincode::serde::encode_to_vec(value, bincode::config::standard()).unwrap();
+ format!("{:x}", Sha256::digest(encoded))
+ }
+
+ // These digests freeze representative generation-1 bincode payloads. A mismatch
+ // requires a new named codec; do not update a v1 digest to bless a wire change.
+ // They do not detect appended enum variants, so every type reachable from a v1
+ // payload is also append-closed.
// ---- Round-trip: ClientMessage ----
@@ -1697,6 +1758,29 @@ mod tests {
}
#[test]
+ fn endpoint_control_roundtrip() {
+ let msg = ClientMessage::EndpointControl {
+ kind: "endpoint.hello.v1".into(),
+ data: r#"{"generation":1}"#.into(),
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ assert_eq!(
+ bincode::serde::encode_to_vec(
+ ClientMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ },
+ bincode::config::standard(),
+ )
+ .unwrap(),
+ [20, 0, 0]
+ );
+ }
+
+ #[test]
fn client_shell_resize_roundtrip() {
let msg = ClientMessage::ClientShellResize {
cell_width_px: 8,
@@ -1708,6 +1792,10 @@ mod tests {
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "676d6376202750e72c45ff511e256b6154d3d20c0ee088d3792fa3a69d9704b9"
+ );
}
#[test]
@@ -1806,6 +1894,51 @@ mod tests {
8
);
assert_eq!(
+ tag(&ClientMessage::GraphicsTransmissionResult {
+ transfer_id: 1,
+ image_id: 2,
+ success: true,
+ }),
+ 9
+ );
+ assert_eq!(
+ tag(&ClientMessage::GraphicsTransmissionStarted {
+ transfer_id: 1,
+ image_id: 2,
+ }),
+ 10
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellResize {
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: ClientSurfaceSize { cols: 80, rows: 29 },
+ pixel_mouse: false,
+ }),
+ 12
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellPaneInput {
+ pane_id: "pane".into(),
+ events: Vec::new(),
+ }),
+ 13
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellPopupInput {
+ terminal_id: "popup".into(),
+ events: Vec::new(),
+ }),
+ 14
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellEndpointRequest {
+ boot_id: "boot".into(),
+ request: "{}".into(),
+ }),
+ 15
+ );
+ assert_eq!(
tag(&ClientMessage::AttachMouse {
kind: ClientMouseKind::Down(ClientMouseButton::Left),
position: ClientMousePosition::Cell { column: 10, row: 5 },
@@ -1826,6 +1959,13 @@ mod tests {
tag(&ClientMessage::ClientShellMouseCapture { enabled: true }),
19
);
+ assert_eq!(
+ tag(&ClientMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ }),
+ 20
+ );
}
#[test]
@@ -1871,6 +2011,10 @@ mod tests {
bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
.expect("decode targeted semantic input");
assert_eq!(decoded, message);
+ assert_eq!(
+ encoded_sha256(&message),
+ "f558384bb53dfd2baf1fa72e1709d88be6891da79e51f88513905afc085065e6"
+ );
let ClientMessage::ClientShellPaneInput { events, .. } = decoded else {
panic!("expected targeted semantic input");
};
@@ -1977,6 +2121,10 @@ mod tests {
bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
.expect("decode endpoint request");
assert_eq!(decoded, request);
+ assert_eq!(
+ encoded_sha256(&request),
+ "de5693585a01f6b0d5ee07c51b6ddf79ee9f67dbf183255822d31f35210f5ffb"
+ );
let response = ServerMessage::ClientShellEndpointResponseChunk {
boot_id: "boot-a".into(),
@@ -1990,7 +2138,12 @@ mod tests {
bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
.expect("decode endpoint response");
assert_eq!(decoded, response);
+ assert_eq!(
+ encoded_sha256(&response),
+ "bc14dbb5263d3097fe6d3e70a4b6d71aa9c2fa4ae3206d692a7182512bffdd1d"
+ );
}
+
#[test]
fn client_clipboard_image_roundtrip() {
let msg = ClientMessage::ClipboardImage {
@@ -2002,6 +2155,10 @@ mod tests {
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "1c02110be0671faf318b3f4d8f507748d5a0a98cd474832669c5290d97ef19ab"
+ );
}
#[test]
@@ -2203,6 +2360,10 @@ mod tests {
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "7c016f7b21ddb5ac79212cf65a968b93eb292b5305b941263e89ffaa40158ee3"
+ );
match decoded {
ServerMessage::PaneSurface(surface) => {
assert_eq!(surface.frame.cells[2].hyperlink, Some(0));
@@ -2246,6 +2407,162 @@ mod tests {
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(decoded, msg);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "0814b99a1dc6eaf7918424aa416c066509cbfb73b72344a809c27cde78cb6dbd"
+ );
+ }
+
+ #[test]
+ fn client_shell_graphics_payload_codec_is_frozen() {
+ let key = SurfaceGraphicsAssetKey {
+ source: SurfaceGraphicsSource::Terminal {
+ target: SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ image_id: 7,
+ },
+ image_width: 2,
+ image_height: 1,
+ format: SurfaceGraphicsFormat::Rgba,
+ data_len: 8,
+ data_fingerprint: 42,
+ };
+ let message = ServerMessage::PaneSurface(PaneSurfaceFrame {
+ boot_id: "boot-1".into(),
+ projection_revision: 2,
+ surface_revision: 3,
+ frame: FrameData {
+ cells: Vec::new(),
+ width: 0,
+ height: 0,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: Vec::new(),
+ splits: Vec::new(),
+ popup: None,
+ graphics: SurfaceGraphicsScene {
+ assets: vec![SurfaceGraphicsAsset {
+ key: key.clone(),
+ data: vec![255, 0, 0, 255, 0, 255, 0, 255],
+ }],
+ placements: vec![SurfaceGraphicsPlacement {
+ asset: key,
+ logical_placement_id: 9,
+ x: 1,
+ y: 2,
+ cols: 2,
+ rows: 1,
+ source_x: 0,
+ source_y: 0,
+ source_width: 2,
+ source_height: 1,
+ x_offset: 0,
+ y_offset: 0,
+ z: -1,
+ scrollback_offset: 0,
+ }],
+ retained_assets: Vec::new(),
+ },
+ });
+
+ assert_eq!(
+ encoded_sha256(&message),
+ "49c4efec0f1456c8ca4112ddf6ead1ab75d0224007576c2ccc18c3fca55a69f0"
+ );
+ }
+
+ #[test]
+ fn server_endpoint_control_tag_is_frozen() {
+ let message = ServerMessage::EndpointControl {
+ kind: "endpoint.welcome.v1".into(),
+ data: r#"{"generation":1}"#.into(),
+ };
+ let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap();
+ assert_eq!(encoded.first(), Some(&20));
+ assert_eq!(
+ bincode::serde::encode_to_vec(
+ ServerMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ },
+ bincode::config::standard(),
+ )
+ .unwrap(),
+ [20, 0, 0]
+ );
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(decoded, message);
+ }
+
+ #[test]
+ fn client_shell_server_message_tags_are_frozen() {
+ fn tag(message: &ServerMessage) -> u8 {
+ *bincode::serde::encode_to_vec(message, bincode::config::standard())
+ .unwrap()
+ .first()
+ .expect("encoded server message should include enum tag")
+ }
+
+ let empty_frame = || PaneSurfaceFrame {
+ boot_id: "boot".into(),
+ projection_revision: 1,
+ surface_revision: 1,
+ frame: FrameData {
+ cells: Vec::new(),
+ width: 0,
+ height: 0,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: Vec::new(),
+ splits: Vec::new(),
+ popup: None,
+ graphics: SurfaceGraphicsScene::default(),
+ };
+ assert_eq!(tag(&ServerMessage::PaneSurface(empty_frame())), 13);
+ assert_eq!(
+ tag(&ServerMessage::ClientShellError {
+ message: String::new(),
+ }),
+ 15
+ );
+ assert_eq!(
+ tag(&ServerMessage::ClientShellKeyboardReportAll { enabled: false }),
+ 17
+ );
+ assert_eq!(
+ tag(&ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id: String::new(),
+ request_id: String::new(),
+ final_chunk: true,
+ data: Vec::new(),
+ }),
+ 18
+ );
+ assert_eq!(
+ tag(&ServerMessage::PaneSurfacePatch(PaneSurfacePatch {
+ boot_id: String::new(),
+ projection_revision: 0,
+ base_surface_revision: 0,
+ surface_revision: 0,
+ rows: Vec::new(),
+ panes: Vec::new(),
+ cursor: None,
+ })),
+ 19
+ );
+ assert_eq!(
+ tag(&ServerMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ }),
+ 20
+ );
}
#[test]
@@ -2411,6 +2728,10 @@ mod tests {
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "28a420f92e0e05e6760a8c140baf307c360c6d1b1aa68027481b324f87e22c44"
+ );
}
#[test]