aboutsummaryrefslogtreecommitdiff
path: root/src/server/client_commands.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/server/client_commands.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/server/client_commands.rs')
-rw-r--r--src/server/client_commands.rs240
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(