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}
61
62/// Session info derived from the OS at connect time, surfaced back
63/// to the client so the UI can label "logged in as
64/// `DOMAIN\\alice`" without a second round-trip.
65#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
66pub struct HandshakeSession {
67 /// `DOMAIN\\username` (Windows) or `username` (Linux/macOS).
68 /// Derived from the OS token, not the payload.
69 pub user: String,
70 /// Console / RDP session id (Windows). On Linux/macOS this is
71 /// the UID (which serves the same "which interactive shell is
72 /// the caller in" role).
73 pub session_id: u32,
74 /// The PC's `pc_id` (matches the agent's `pc_id` everywhere
75 /// else — `Heartbeat`, `ExecResult`, audit log, …). Carried in
76 /// the handshake so the client's UI doesn't need a separate
77 /// "which PC am I?" call.
78 pub pc_id: String,
79}
80
81/// Well-known feature flag names. New flags are added here so the
82/// agent + client + dispatcher all share one source of truth. SPEC
83/// §2.12.6 says optional methods MUST be gated via these flags so
84/// older clients/agents degrade gracefully.
85pub mod features {
86 pub const PUSH_NOTIFICATIONS: &str = "push.notifications";
87 pub const PUSH_JOBS: &str = "push.jobs";
88 pub const PUSH_STATE: &str = "push.state";
89 pub const SUPPORT_DIAGNOSTICS: &str = "support.diagnostics";
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn handshake_params_round_trips_through_json() {
98 let p = HandshakeParams {
99 client: "kanade-client".into(),
100 client_version: "0.1.0".into(),
101 protocol: vec![PROTOCOL_V1],
102 features: vec![
103 features::PUSH_NOTIFICATIONS.into(),
104 features::PUSH_JOBS.into(),
105 features::PUSH_STATE.into(),
106 ],
107 };
108 let json = serde_json::to_string(&p).unwrap();
109 let back: HandshakeParams = serde_json::from_str(&json).unwrap();
110 assert_eq!(back.client, "kanade-client");
111 assert_eq!(back.client_version, "0.1.0");
112 assert_eq!(back.protocol, vec![PROTOCOL_V1]);
113 assert!(back.features.contains(&"push.notifications".to_string()));
114 }
115
116 #[test]
117 fn handshake_params_accepts_missing_features() {
118 // Older / lighter clients may omit `features` entirely. The
119 // serde default keeps the decode green.
120 let wire = r#"{"client":"kanade-client","client_version":"0.0.1","protocol":[1]}"#;
121 let p: HandshakeParams = serde_json::from_str(wire).unwrap();
122 assert!(p.features.is_empty());
123 }
124
125 #[test]
126 fn handshake_result_spec_example_decodes() {
127 // Verbatim from SPEC §2.12.6 — pinned so a struct rename
128 // can't silently break the documented contract.
129 let wire = r#"{
130 "protocol": 1,
131 "agent_version": "0.4.0",
132 "features": ["push.notifications","push.jobs","push.state","support.diagnostics"],
133 "session": {"user":"DOMAIN\\alice","session_id":2,"pc_id":"PC1234"}
134 }"#;
135 let r: HandshakeResult = serde_json::from_str(wire).expect("decode");
136 assert_eq!(r.protocol, 1);
137 assert_eq!(r.agent_version, "0.4.0");
138 assert_eq!(r.features.len(), 4);
139 assert_eq!(r.session.user, "DOMAIN\\alice");
140 assert_eq!(r.session.session_id, 2);
141 assert_eq!(r.session.pc_id, "PC1234");
142 }
143
144 #[test]
145 fn handshake_protocol_negotiation_supports_multiple_versions() {
146 // When v2 lands, clients will advertise `protocol:[1,2]`.
147 // The struct must accept multi-element arrays today so the
148 // upgrade path doesn't need a wire change.
149 let wire = r#"{"client":"c","client_version":"0.0.1","protocol":[1,2,3]}"#;
150 let p: HandshakeParams = serde_json::from_str(wire).unwrap();
151 assert_eq!(p.protocol, vec![1, 2, 3]);
152 }
153}