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}