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/server/client_commands.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/server/client_commands.rs')
| -rw-r--r-- | src/server/client_commands.rs | 240 |
1 files changed, 200 insertions, 40 deletions
diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index eeb49c7e..86243e31 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -12,47 +12,56 @@ pub(crate) const MAX_ENDPOINT_BOOT_ID_BYTES: usize = 128; pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128; const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024; +const CLIENT_SHELL_METHODS: &[&str] = &[ + "command.invoke", + "integration.install", + "integration.list", + "layout.set_split_ratio", + "pane.close", + "pane.copy_motion", + "pane.copy_search", + "pane.edit_scrollback", + "pane.focus", + "pane.focus_direction", + "pane.input.set", + "pane.link.activate", + "pane.rename", + "pane.resize", + "pane.scroll", + "pane.selection.read", + "pane.split", + "pane.swap", + "pane.zoom", + "product_announcement.dismiss", + "release_notes.dismiss", + "server.reload_config", + "tab.close", + "tab.create", + "tab.focus", + "tab.move", + "tab.rename", + "workspace.close", + "workspace.create", + "workspace.focus", + "workspace.move", + "workspace.move_block", + "workspace.rename", + "worktree.create", + "worktree.list", + "worktree.open", + "worktree.remove", +]; + +pub(crate) fn supported_client_shell_method_names() -> &'static [&'static str] { + CLIENT_SHELL_METHODS +} + +pub(crate) fn supports_client_shell_method_name(method: &str) -> bool { + CLIENT_SHELL_METHODS.contains(&method) +} + pub(crate) fn supports_client_shell_method(method: &Method) -> bool { - matches!( - method, - Method::CommandInvoke(_) - | Method::IntegrationInstall(_) - | Method::IntegrationList(_) - | Method::LayoutSetSplitRatio(_) - | Method::PaneClose(_) - | Method::PaneCopyMotion(_) - | Method::PaneCopySearch(_) - | Method::PaneEditScrollback(_) - | Method::PaneFocus(_) - | Method::PaneFocusDirection(_) - | Method::PaneInputSet(_) - | Method::PaneLinkActivate(_) - | Method::PaneRename(_) - | Method::PaneResize(_) - | Method::PaneScroll(_) - | Method::PaneSelectionRead(_) - | Method::PaneSplit(_) - | Method::PaneSwap(_) - | Method::PaneZoom(_) - | Method::ProductAnnouncementDismiss(_) - | Method::ReleaseNotesDismiss(_) - | Method::ServerReloadConfig(_) - | Method::TabClose(_) - | Method::TabCreate(_) - | Method::TabFocus(_) - | Method::TabMove(_) - | Method::TabRename(_) - | Method::WorkspaceClose(_) - | Method::WorkspaceCreate(_) - | Method::WorkspaceFocus(_) - | Method::WorkspaceMove(_) - | Method::WorkspaceMoveBlock(_) - | Method::WorkspaceRename(_) - | Method::WorktreeCreate(_) - | Method::WorktreeList(_) - | Method::WorktreeOpen(_) - | Method::WorktreeRemove(_) - ) + supports_client_shell_method_name(crate::api::api_method_name(method)) } pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>) -> String { @@ -134,8 +143,159 @@ pub(crate) fn spawn_response_waiter( #[cfg(test)] mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use sha2::{Digest, Sha256}; + use super::*; + fn collect_schema_refs(value: &serde_json::Value, refs: &mut BTreeSet<String>) { + match value { + serde_json::Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(serde_json::Value::as_str) { + if let Some(name) = reference.rsplit('/').next() { + refs.insert(name.to_owned()); + } + } + for value in object.values() { + collect_schema_refs(value, refs); + } + } + serde_json::Value::Array(values) => { + for value in values { + collect_schema_refs(value, refs); + } + } + _ => {} + } + } + + fn normalized_wire_schema(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(object) => serde_json::Value::Object( + object + .iter() + .filter(|(key, _)| { + !matches!( + key.as_str(), + "description" | "examples" | "readOnly" | "title" | "writeOnly" + ) + }) + .map(|(key, value)| (key.clone(), normalized_wire_schema(value))) + .collect(), + ), + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.iter().map(normalized_wire_schema).collect()) + } + _ => value.clone(), + } + } + + fn endpoint_method_shape_digests() -> BTreeMap<String, String> { + let schema = serde_json::to_value(schemars::schema_for!(crate::api::schema::Request)) + .expect("request schema"); + let definitions = schema + .get("$defs") + .and_then(serde_json::Value::as_object) + .expect("request definitions"); + let branches = schema + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("request method branches"); + let mut digests = BTreeMap::new(); + + for method in CLIENT_SHELL_METHODS { + let branch = branches + .iter() + .find(|branch| { + branch + .pointer("/properties/method/const") + .and_then(serde_json::Value::as_str) + == Some(method) + }) + .unwrap_or_else(|| panic!("missing request schema branch for {method}")); + let mut referenced_names = BTreeSet::new(); + collect_schema_refs(branch, &mut referenced_names); + let mut visited_names = BTreeSet::new(); + let mut selected_definitions = serde_json::Map::new(); + while let Some(name) = referenced_names.pop_first() { + if !visited_names.insert(name.clone()) { + continue; + } + let definition = definitions + .get(&name) + .unwrap_or_else(|| panic!("missing schema definition {name} for {method}")); + collect_schema_refs(definition, &mut referenced_names); + selected_definitions.insert(name, normalized_wire_schema(definition)); + } + let shape = serde_json::json!({ + "request": normalized_wire_schema(branch), + "definitions": selected_definitions, + }); + let bytes = serde_json::to_vec(&shape).expect("method shape json"); + digests.insert(method.to_string(), format!("{:x}", Sha256::digest(bytes))); + } + + digests + } + + #[test] + fn advertised_client_shell_method_shapes_stay_at_the_v1_contract() { + let expected: BTreeMap<String, String> = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/endpoint-method-shapes-v1.json" + ))) + .expect("endpoint method shape fixture"); + let actual = endpoint_method_shape_digests(); + + assert_eq!( + actual, + expected, + "an existing endpoint method changed shape; add load-bearing behavior as a new advertised method or explicitly gate new fields" + ); + } + + #[test] + fn advertised_client_shell_methods_are_sorted_unique_and_in_schema() { + assert!(CLIENT_SHELL_METHODS + .windows(2) + .all(|pair| pair[0] < pair[1])); + + fn collect_method_constants(value: &serde_json::Value, methods: &mut Vec<String>) { + match value { + serde_json::Value::Object(object) => { + if let Some(method) = object + .get("const") + .and_then(serde_json::Value::as_str) + .filter(|value| value.contains('.')) + { + methods.push(method.to_owned()); + } + for value in object.values() { + collect_method_constants(value, methods); + } + } + serde_json::Value::Array(values) => { + for value in values { + collect_method_constants(value, methods); + } + } + _ => {} + } + } + + let schema = serde_json::to_value(schemars::schema_for!(crate::api::schema::Request)) + .expect("request schema"); + let mut schema_methods = Vec::new(); + collect_method_constants(&schema, &mut schema_methods); + for method in CLIENT_SHELL_METHODS { + assert!( + schema_methods.iter().any(|candidate| candidate == method), + "advertised endpoint method {method:?} is absent from the request schema" + ); + } + } + #[test] fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() { assert!(supports_client_shell_method(&Method::ServerReloadConfig( |
