Skip to main content

kanade_shared/ipc/
handshake.rs

1//! `system.handshake` types (SPEC §2.12.6).
2//!
3//! Every KLP connection's first request. Negotiates the protocol
4//! version and the agent's optional features, and returns the
5//! OS-derived session info (user SID + console session id + pc_id)
6//! the client uses to label its UI and audit-log entries.
7//!
8//! Until handshake completes, the agent rejects every other method
9//! with [`super::error::ErrorKind::InvalidRequest`] (SPEC §2.12.6
10//! "Handshake 未完了の状態で他 method を呼ぶと -32600 InvalidRequest").
11
12use serde::{Deserialize, Serialize};
13
14/// The single protocol version KLP v1 ships with. Listed in
15/// [`HandshakeParams::protocol`] by the client; the agent picks the
16/// highest mutually-supported version into
17/// [`HandshakeResult::protocol`]. If no overlap, the agent returns
18/// [`super::error::ErrorKind::StaleProtocol`].
19pub const PROTOCOL_V1: u32 = 1;
20
21/// `system.handshake` request params.
22#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
23pub struct HandshakeParams {
24    /// Client identifier (e.g. `"kanade-client"`). Free-form, used
25    /// for the audit log + tracing spans. Agent does not gate on
26    /// this — alternate clients (test harnesses, ops CLI) are fine.
27    pub client: String,
28    /// Client binary version (semver). Surfaced into the audit log.
29    pub client_version: String,
30    /// All protocol versions the client can speak. The agent picks
31    /// the highest mutually-supported version into
32    /// [`HandshakeResult::protocol`]. Length must be ≥ 1 — an empty
33    /// array returns `InvalidParams`.
34    pub protocol: Vec<u32>,
35    /// Optional features the client wants to use. Agent answers
36    /// with its own [`HandshakeResult::features`] set; the
37    /// intersection is what's actually live for this connection.
38    /// Defaults to `[]` for clients that need only the core methods.
39    #[serde(default)]
40    pub features: Vec<String>,
41}
42
43/// `system.handshake` response result.
44#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
45pub struct HandshakeResult {
46    /// Protocol version the agent picked from
47    /// [`HandshakeParams::protocol`].
48    pub protocol: u32,
49    /// Agent binary version. Drives the client's "Agent version
50    /// mismatch — restart?" toast.
51    pub agent_version: String,
52    /// Features the agent itself supports. Per SPEC §2.12.6 this
53    /// MAY be a superset of what the client asked for — the client
54    /// is expected to enable only the intersection.
55    pub features: Vec<String>,
56    /// Session identity (SPEC §2.12.4) — agent reads this from the
57    /// OS at connect time, not from the client payload, so the
58    /// values here are authoritative.
59    pub session: HandshakeSession,
60    /// Operator-configured product name the client should display in
61    /// its window title + header (from the agent's effective
62    /// `agent_config.client_display_name`). `None` when no scope sets
63    /// one — the client then falls back to its built-in default name.
64    /// `#[serde(default)]` so a pre-this-field agent (which never
65    /// emits the key) still decodes against a newer client, and
66    /// `skip_serializing_if` keeps the wire tight when unset.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub client_display_name: Option<String>,
69}
70
71/// Session info derived from the OS at connect time, surfaced back
72/// to the client so the UI can label "logged in as
73/// `DOMAIN\\alice`" without a second round-trip.
74#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
75pub struct HandshakeSession {
76    /// `DOMAIN\\username` (Windows) or `username` (Linux/macOS).
77    /// Derived from the OS token, not the payload.
78    pub user: String,
79    /// Console / RDP session id (Windows). On Linux/macOS this is
80    /// the UID (which serves the same "which interactive shell is
81    /// the caller in" role).
82    pub session_id: u32,
83    /// The PC's `pc_id` (matches the agent's `pc_id` everywhere
84    /// else — `Heartbeat`, `ExecResult`, audit log, …). Carried in
85    /// the handshake so the client's UI doesn't need a separate
86    /// "which PC am I?" call.
87    pub pc_id: String,
88}
89
90/// Well-known feature flag names. New flags are added here so the
91/// agent + client + dispatcher all share one source of truth. SPEC
92/// §2.12.6 says optional methods MUST be gated via these flags so
93/// older clients/agents degrade gracefully.
94pub mod features {
95    pub const PUSH_NOTIFICATIONS: &str = "push.notifications";
96    pub const PUSH_JOBS: &str = "push.jobs";
97    pub const PUSH_STATE: &str = "push.state";
98    pub const SUPPORT_DIAGNOSTICS: &str = "support.diagnostics";
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn handshake_params_round_trips_through_json() {
107        let p = HandshakeParams {
108            client: "kanade-client".into(),
109            client_version: "0.1.0".into(),
110            protocol: vec![PROTOCOL_V1],
111            features: vec![
112                features::PUSH_NOTIFICATIONS.into(),
113                features::PUSH_JOBS.into(),
114                features::PUSH_STATE.into(),
115            ],
116        };
117        let json = serde_json::to_string(&p).unwrap();
118        let back: HandshakeParams = serde_json::from_str(&json).unwrap();
119        assert_eq!(back.client, "kanade-client");
120        assert_eq!(back.client_version, "0.1.0");
121        assert_eq!(back.protocol, vec![PROTOCOL_V1]);
122        assert!(back.features.contains(&"push.notifications".to_string()));
123    }
124
125    #[test]
126    fn handshake_params_accepts_missing_features() {
127        // Older / lighter clients may omit `features` entirely. The
128        // serde default keeps the decode green.
129        let wire = r#"{"client":"kanade-client","client_version":"0.0.1","protocol":[1]}"#;
130        let p: HandshakeParams = serde_json::from_str(wire).unwrap();
131        assert!(p.features.is_empty());
132    }
133
134    #[test]
135    fn handshake_result_spec_example_decodes() {
136        // Verbatim from SPEC §2.12.6 — pinned so a struct rename
137        // can't silently break the documented contract.
138        let wire = r#"{
139            "protocol": 1,
140            "agent_version": "0.4.0",
141            "features": ["push.notifications","push.jobs","push.state","support.diagnostics"],
142            "session": {"user":"DOMAIN\\alice","session_id":2,"pc_id":"PC1234"}
143        }"#;
144        let r: HandshakeResult = serde_json::from_str(wire).expect("decode");
145        assert_eq!(r.protocol, 1);
146        assert_eq!(r.agent_version, "0.4.0");
147        assert_eq!(r.features.len(), 4);
148        assert_eq!(r.session.user, "DOMAIN\\alice");
149        assert_eq!(r.session.session_id, 2);
150        assert_eq!(r.session.pc_id, "PC1234");
151    }
152
153    #[test]
154    fn handshake_result_omits_client_display_name_when_unset() {
155        // A pre-this-field agent emits no `client_display_name` key;
156        // skip_serializing_if keeps the wire identical to the old
157        // shape, and serde(default) decodes it back to None.
158        let r = HandshakeResult {
159            protocol: 1,
160            agent_version: "0.43.0".into(),
161            features: vec![],
162            session: HandshakeSession {
163                user: "DOMAIN\\alice".into(),
164                session_id: 2,
165                pc_id: "PC1".into(),
166            },
167            client_display_name: None,
168        };
169        let json = serde_json::to_string(&r).unwrap();
170        assert!(
171            !json.contains("client_display_name"),
172            "unset name must not appear on the wire: {json}"
173        );
174        let back: HandshakeResult = serde_json::from_str(&json).unwrap();
175        assert!(back.client_display_name.is_none());
176    }
177
178    #[test]
179    fn handshake_result_round_trips_client_display_name() {
180        let r = HandshakeResult {
181            protocol: 1,
182            agent_version: "0.43.0".into(),
183            features: vec![],
184            session: HandshakeSession {
185                user: "DOMAIN\\alice".into(),
186                session_id: 2,
187                pc_id: "PC1".into(),
188            },
189            client_display_name: Some("端末管理支援ツール".into()),
190        };
191        let json = serde_json::to_string(&r).unwrap();
192        let back: HandshakeResult = serde_json::from_str(&json).unwrap();
193        assert_eq!(
194            back.client_display_name.as_deref(),
195            Some("端末管理支援ツール")
196        );
197    }
198
199    #[test]
200    fn handshake_protocol_negotiation_supports_multiple_versions() {
201        // When v2 lands, clients will advertise `protocol:[1,2]`.
202        // The struct must accept multi-element arrays today so the
203        // upgrade path doesn't need a wire change.
204        let wire = r#"{"client":"c","client_version":"0.0.1","protocol":[1,2,3]}"#;
205        let p: HandshakeParams = serde_json::from_str(wire).unwrap();
206        assert_eq!(p.protocol, vec![1, 2, 3]);
207    }
208}