//! Non-tool protocol payloads (`system.handshake`, `system.ping`, `system.status`). use std::cmp::Ordering; use schemars::JsonSchema; use semver::Version; use serde::{Deserialize, Serialize}; /// Outcome of comparing peer + local **protocol** versions during the /// `system.handshake` exchange (design §10, M10.4). /// /// ```text /// protocol unparseable or major differs → Reject /// peer_protocol < our_min_compatible_protocol → Reject /// our_protocol < peer_min_compatible_protocol → Reject (skipped when peer omits floor) /// peer_protocol != our_protocol (same major) → Skew (warn but allow) /// otherwise → Ok /// ``` /// /// Application semvers (`HandshakeParams::version`, etc.) are **not** /// part of this decision — CLI and extension ship on independent /// version lines. Legacy `min_compatible_peer` is optional and ignored /// here. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HandshakeCompat { /// Protocol strings match exactly; no UX surface needs to flag skew. Ok, /// Same protocol major and both protocol floors satisfied, but the /// full `protocol_version` strings differ (minor drift). Connection /// is accepted; surfaces render "warn but allow". Skew, /// Connection must be refused. `reason` is a short /// human-readable explanation suitable for the WS error frame. Reject { reason: String }, } /// Evaluate handshake compatibility using **protocol_version** only. /// /// `peer_min_compatible_protocol = None` when the peer is a legacy build /// that predates the field — the "local below peer protocol floor" branch /// is skipped in that case. pub fn evaluate_handshake_compat( peer_protocol_version: &str, peer_min_compatible_protocol: Option<&str>, our_protocol_version: &str, our_min_compatible_protocol: &str, ) -> HandshakeCompat { let peer_major = parse_major(peer_protocol_version); let our_major = parse_major(our_protocol_version); if peer_major.is_none() || our_major.is_none() || peer_major != our_major { return HandshakeCompat::Reject { reason: format!( "protocol major mismatch (peer={peer_protocol_version}, local={our_protocol_version})" ), }; } match compare_protocol(peer_protocol_version, our_min_compatible_protocol) { Some(Ordering::Less) => { return HandshakeCompat::Reject { reason: format!( "peer protocol {peer_protocol_version} is below local min_compatible_protocol {our_min_compatible_protocol}" ), }; } None => { return HandshakeCompat::Reject { reason: format!("peer protocol version unparseable: {peer_protocol_version}"), }; } _ => {} } if let Some(peer_floor) = peer_min_compatible_protocol { match compare_protocol(our_protocol_version, peer_floor) { Some(Ordering::Less) => { return HandshakeCompat::Reject { reason: format!( "local protocol {our_protocol_version} is below peer min_compatible_protocol {peer_floor}" ), }; } None => { if parse_protocol_parts(our_protocol_version).is_none() { return HandshakeCompat::Reject { reason: format!( "local protocol version unparseable: {our_protocol_version}" ), }; } return HandshakeCompat::Reject { reason: format!("peer min_compatible_protocol unparseable: {peer_floor}"), }; } _ => {} } } if peer_protocol_version != our_protocol_version { return HandshakeCompat::Skew; } HandshakeCompat::Ok } /// Parsed `(major, minor)` pair for a wire `protocol_version` string. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ProtocolParts { major: u64, minor: u64, } /// Compare two protocol-version strings lexicographically on /// `(major, minor)`. Returns `None` when either side is unparseable. pub fn compare_protocol(a: &str, b: &str) -> Option { let pa = parse_protocol_parts(a)?; let pb = parse_protocol_parts(b)?; Some(pa.major.cmp(&pb.major).then(pa.minor.cmp(&pb.minor))) } fn parse_protocol_parts(value: &str) -> Option { let major = parse_major(value)?; let rest = value.split('.').nth(1); let minor = match rest { None | Some("") => 0, Some(seg) => { if seg.is_empty() || !seg.bytes().all(|b| b.is_ascii_digit()) { return None; } let n: u64 = seg.parse().ok()?; if n > MAX_PROTOCOL_MAJOR { return None; } n } }; Some(ProtocolParts { major, minor }) } /// Upper bound the daemon will accept for the protocol-major field. /// /// Matches JavaScript's `Number.MAX_SAFE_INTEGER` (`2^53 - 1 = /// 9_007_199_254_740_991`) so the daemon ↔ extension parsers reject /// the *same* set of inputs (review round 5 I1). Without this cap a /// peer advertising e.g. `"18446744073709551615"` (u64::MAX) is /// accepted here as `Some(u64::MAX)` but rejected by the TS side's /// `!Number.isSafeInteger(...)` guard, violating "daemon accepts ⇒ /// extension accepts". The TS side intentionally caps at MAX_SAFE_INTEGER /// to avoid silently rounding through `Number()`; pushing the daemon /// down to that same ceiling is far cheaper than teaching the extension /// to compare arbitrary-precision integers (which would force a /// rewrite of `compareSemver` and friends). In practice /// `protocol_version` only ever carries small values like `0` / `1` /// / `2`, so a cap at `2^53 - 1` has zero impact on real traffic. const MAX_PROTOCOL_MAJOR: u64 = (1u64 << 53) - 1; /// Extract the leading numeric major component from a protocol-version /// string such as `"1.0"` or `"1.0.0"`. /// /// We deliberately do NOT use the bare `u64::from_str` accept set here: /// `u64::from_str("+1")` returns `Ok(1)` because the std-lib parser is /// happy to peel off a leading `+`. For a *wire* field that's the wrong /// trade-off — the TypeScript extension's `parseProtocolMajor` /// (`apps/extension/src/lib/semver.ts`) is built around `/^\d+$/` and /// rejects any non-digit prefix. To keep "both peers reach the same /// verdict" as a hard contract (review round 4 I1), the Rust side has /// to be equally strict: take the segment before the first `.`, then /// require it to be a non-empty run of ASCII digits before delegating /// the actual parse. Without this guard a peer advertising `"+1.0"` /// would be accepted by the daemon and rejected by the extension — /// the same asymmetry round 3 fought on the app-version axis. /// /// Round 5 I1 closes the last remaining accept-set gap on the upper /// bound: after the digit guard passes and `head.parse::()` /// succeeds, we also refuse values strictly above /// [`MAX_PROTOCOL_MAJOR`] (= `Number.MAX_SAFE_INTEGER`). This /// matches the TS side's `!Number.isSafeInteger(n)` reject so the /// `MAX_SAFE_INTEGER < n <= u64::MAX` band (which `u64::from_str` /// would otherwise happily accept) is rejected on both peers. fn parse_major(value: &str) -> Option { let head = value.split('.').next()?; if head.is_empty() || !head.bytes().all(|b| b.is_ascii_digit()) { return None; } let n: u64 = head.parse().ok()?; if n > MAX_PROTOCOL_MAJOR { return None; } Some(n) } #[cfg(test)] mod parse_major_tests { //! Round 4 I1: lock the *exact* accept set of `parse_major` so the //! daemon's verdict on the protocol-major field is bit-for-bit //! identical to the extension's `parseProtocolMajor` (see //! `apps/extension/src/lib/__tests__/semver.test.ts`). Any input //! the daemon accepts here must be accepted by the extension; any //! input the daemon rejects must round-trip to `null` over there. //! These cases mirror the extension test names so a future //! contributor who edits one side will see the matching coverage //! on the other. use super::parse_major; #[test] fn rejects_explicit_plus_sign() { // `u64::from_str("+1") = Ok(1)` in std; we MUST refuse so the // daemon's verdict matches the extension's `/^\d+$/` reject. assert_eq!(parse_major("+1"), None); assert_eq!(parse_major("+1.0"), None); } #[test] fn rejects_explicit_minus_sign() { // `u64::from_str("-1")` already fails in std (u64 is unsigned), // but we still pin the behaviour through the new strict path // so it can't accidentally drift if the function is refactored. assert_eq!(parse_major("-1"), None); assert_eq!(parse_major("-1.0"), None); } #[test] fn rejects_leading_whitespace() { assert_eq!(parse_major(" 1"), None); assert_eq!(parse_major(" 1.0"), None); assert_eq!(parse_major("\t1.0"), None); } #[test] fn rejects_empty_input() { assert_eq!(parse_major(""), None); } #[test] fn rejects_empty_leading_segment() { // `split('.').next()` returns `Some("")` for `".1"`, which our // emptiness guard must catch. assert_eq!(parse_major(".1"), None); } #[test] fn rejects_trailing_non_digit_characters() { // Pre-fix `u64::from_str("1x")` already fails, but pin both // shapes so the strict guard is locked. assert_eq!(parse_major("1x"), None); } #[test] fn rejects_scientific_notation() { assert_eq!(parse_major("1e3"), None); } #[test] fn rejects_non_numeric_leading_segment() { assert_eq!(parse_major("x.0"), None); } #[test] fn accepts_leading_zeros() { // `u64::from_str("01")` already accepts leading zeros; the TS // side does the same (`/^\d+$/.test("01")` && `Number("01") = // 1`). Lock the behaviour so a future tightening doesn't // diverge silently. assert_eq!(parse_major("01"), Some(1)); assert_eq!(parse_major("01.0"), Some(1)); } #[test] fn accepts_single_segment() { assert_eq!(parse_major("1"), Some(1)); assert_eq!(parse_major("123"), Some(123)); } #[test] fn accepts_canonical_two_segment() { assert_eq!(parse_major("1.0"), Some(1)); } #[test] fn accepts_canonical_three_segment() { assert_eq!(parse_major("1.0.0"), Some(1)); } #[test] fn ignores_trailing_segments() { // Confirms the `split('.').next()` semantics: anything past // the first `.` is irrelevant, including shapes the minor // segment alone could not parse. assert_eq!(parse_major("1."), Some(1)); assert_eq!(parse_major("1.x"), Some(1)); } #[test] fn accepts_value_at_max_safe_integer() { // 2^53 - 1 = 9_007_199_254_740_991 is the upper bound both // peers agree on (review round 5 I1). It round-trips through // `Number()` losslessly on the TS side, so the daemon must // accept it here too. assert_eq!(parse_major("9007199254740991"), Some(9_007_199_254_740_991)); } #[test] fn rejects_value_above_max_safe_integer() { // Round 5 I1: previously the daemon accepted any `u64::from_str` // value all the way up to `u64::MAX`, which the TS side's // `!Number.isSafeInteger` guard would reject. Cap at // `Number.MAX_SAFE_INTEGER` to keep parser verdicts symmetric. // - MAX_SAFE_INTEGER + 1 (2^53 = 9_007_199_254_740_992): assert_eq!(parse_major("9007199254740992"), None); // - u64::MAX itself (a valid u64 but well above the cap): assert_eq!(parse_major("18446744073709551615"), None); } #[test] fn rejects_value_above_u64_max() { // u64::MAX + 1 (21 digits). The strict ASCII-digit guard // lets it through to `head.parse()` which errors with overflow — // we get `None`, never a wraparound. assert_eq!(parse_major("18446744073709551616"), None); // And a much larger value (23 digits) still fails cleanly. assert_eq!(parse_major("99999999999999999999999"), None); } } #[cfg(test)] mod compare_protocol_tests { use super::*; #[test] fn equal_protocols() { assert_eq!(compare_protocol("1.0", "1.0"), Some(Ordering::Equal)); } #[test] fn minor_ordering() { assert_eq!(compare_protocol("1.1", "1.0"), Some(Ordering::Greater)); assert_eq!(compare_protocol("1.0", "1.1"), Some(Ordering::Less)); } #[test] fn major_only_treated_as_zero_minor() { assert_eq!(compare_protocol("1", "1.0"), Some(Ordering::Equal)); } #[test] fn unparseable_returns_none() { assert_eq!(compare_protocol("oops", "1.0"), None); assert_eq!(compare_protocol("1.0", "1.x"), None); } } #[cfg(test)] mod compat_tests { use super::*; #[test] fn ok_when_protocol_strings_match() { let outcome = evaluate_handshake_compat("1.0", Some("1.0"), "1.0", "1.0"); assert_eq!(outcome, HandshakeCompat::Ok); } #[test] fn skew_when_same_major_different_minor() { let outcome = evaluate_handshake_compat("1.1", Some("1.0"), "1.0", "1.0"); assert_eq!(outcome, HandshakeCompat::Skew); } #[test] fn skew_when_peer_minor_newer() { let outcome = evaluate_handshake_compat("1.0", Some("1.0"), "1.1", "1.0"); assert_eq!(outcome, HandshakeCompat::Skew); } #[test] fn reject_when_peer_below_protocol_floor() { let outcome = evaluate_handshake_compat("0.9", Some("0.5"), "1.0", "1.0"); assert!(matches!(outcome, HandshakeCompat::Reject { .. })); } #[test] fn reject_when_self_below_peer_protocol_floor() { let outcome = evaluate_handshake_compat("1.0", Some("1.5"), "1.0", "1.0"); match outcome { HandshakeCompat::Reject { reason } => assert!( reason.contains("local protocol") && reason.contains("min_compatible_protocol"), "reason: {reason}" ), other => panic!("expected reject, got {other:?}"), } } #[test] fn reject_when_peer_protocol_floor_unparseable_reports_peer_floor() { let outcome = evaluate_handshake_compat("1.0", Some("not-a-protocol"), "1.0", "1.0"); match outcome { HandshakeCompat::Reject { reason } => assert!( reason.contains("peer min_compatible_protocol") && reason.contains("not-a-protocol"), "reason: {reason}" ), other => panic!("expected reject, got {other:?}"), } } #[test] fn ok_when_peer_protocol_floor_absent() { let outcome = evaluate_handshake_compat("1.0", None, "1.0", "1.0"); assert_eq!(outcome, HandshakeCompat::Ok); } #[test] fn reject_when_protocol_major_differs() { let outcome = evaluate_handshake_compat("2.0", Some("1.0"), "1.0", "1.0"); assert!(matches!(outcome, HandshakeCompat::Reject { .. })); } #[test] fn reject_when_protocol_string_unparseable() { let outcome = evaluate_handshake_compat("oops", Some("1.0"), "1.0", "1.0"); assert!(matches!(outcome, HandshakeCompat::Reject { .. })); } #[test] fn app_version_drift_does_not_affect_verdict() { // CLI 0.1.0 ↔ extension 9.9.9 with matching protocol → Ok let outcome = evaluate_handshake_compat("1.0", Some("1.0"), "1.0", "1.0"); assert_eq!(outcome, HandshakeCompat::Ok); } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct BrowserPeerInfo { pub name: String, pub version: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct HandshakeParams { pub client: String, #[schemars(with = "String")] pub version: Version, /// Logical protocol revision (for example `"1.0"`), independent of SemVer app releases. pub protocol_version: String, pub instance_id: String, pub browser: BrowserPeerInfo, pub label: String, /// **Deprecated** — legacy app-semver floor kept for wire compat with /// pre-protocol peers. New code sends `"0.0.0"` and ignores on read. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(with = "Option")] pub min_compatible_peer: Option, /// Lowest peer **protocol** version this side accepts (e.g. `"1.0"`). #[serde(default, skip_serializing_if = "Option::is_none")] pub min_compatible_protocol: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct HandshakeResult { pub server: String, #[schemars(with = "String")] pub version: Version, pub protocol_version: String, /// **Deprecated** — legacy app-semver floor. New peers should use /// `min_compatible_protocol`; this field is optional and ignored. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(with = "Option")] pub min_compatible_peer: Option, /// Lowest peer protocol version this daemon accepts. #[serde(default, skip_serializing_if = "Option::is_none")] pub min_compatible_protocol: Option, } #[cfg(test)] mod handshake_payload_tests { use super::*; #[test] fn handshake_result_deserializes_without_legacy_min_compatible_peer() { let result: HandshakeResult = serde_json::from_value(serde_json::json!({ "server": "browser-skill-daemon", "version": "0.1.0", "protocol_version": "1.0", "min_compatible_protocol": "1.0" })) .expect("legacy app-semver floor is optional in new handshake results"); assert_eq!(result.min_compatible_peer, None); assert_eq!(result.min_compatible_protocol.as_deref(), Some("1.0")); } } /// `system.ping` request payload. Empty (the response carries `pong`). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct PingParams {} /// `system.ping` response payload. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct PingResult { pub pong: bool, } /// Snapshot of a single connected extension (extension client view). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct BrowserStatusEntry { /// Stable id assigned by the extension; matches `HandshakeParams::instance_id`. pub instance_id: String, pub browser_name: String, pub browser_version: String, pub extension_version: String, pub label: String, pub session_count: u32, /// Unix epoch milliseconds at which the extension completed the /// daemon handshake. Stable across reconnects only via the /// best-effort registry generation; clients should treat this as a /// rough "online since" hint, not a strong identity. #[serde(default)] pub connected_at_ms: i64, /// `true` when the browser's `protocol_version` differs from the /// daemon's (same major, minor drift). Connection is still allowed. #[serde(default)] pub version_skew: bool, /// Protocol version the extension advertised at handshake. #[serde(default)] pub extension_protocol_version: String, } /// Snapshot of a single live session. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct SessionStatusEntry { pub session_id: String, pub browser_instance_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_window_id: Option, /// Unix epoch milliseconds. pub created_at_ms: i64, } /// `system.status` request payload. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct StatusParams { /// When set, the daemon polls the browser registry for up to this many /// milliseconds before returning a snapshot. Only applies while zero /// browsers are registered; an existing connection returns immediately. #[serde(default, skip_serializing_if = "Option::is_none")] pub wait_for_browser_ms: Option, } /// `browser.list` request payload. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct BrowserListParams { /// When set, the daemon polls the browser registry for up to this many /// milliseconds before returning a list. Only applies while zero browsers /// are registered; an existing connection returns immediately. #[serde(default, skip_serializing_if = "Option::is_none")] pub wait_for_browser_ms: Option, } /// One entry per connected browser whose protocol version drifts from /// the daemon's (same major). Surfaced in `system.status` for /// `bsk status` / `bsk doctor` / popup "warn but allow" UX. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct VersionSkewEntry { pub instance_id: String, pub browser_name: String, pub label: String, /// Daemon app semver at snapshot time (informational). pub server_version: String, /// Extension app semver at snapshot time (informational). pub client_version: String, /// Daemon `protocol_version` at snapshot time. #[serde(default)] pub server_protocol_version: String, /// Extension `protocol_version` from handshake. #[serde(default)] pub client_protocol_version: String, } /// `system.status` reply: daemon meta + connected browsers + live sessions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct StatusResult { /// Semver of the daemon binary (`env!("CARGO_PKG_VERSION")`). pub daemon_version: String, /// Logical protocol revision (e.g. `"1.0"`). pub protocol_version: String, /// Daemon process id. pub pid: u32, /// Daemon uptime in whole seconds. pub uptime_secs: u64, /// WS port the daemon listens on for extension clients. pub ws_port: u16, /// Absolute path of the IPC socket / named pipe handle. pub sock_path: String, /// Snapshot of connected extension clients. pub browsers: Vec, /// Snapshot of live sessions. pub sessions: Vec, /// Subset of `browsers` whose `protocol_version` differs from the /// daemon's (minor drift, same major). Entries mirror /// `BrowserStatusEntry.version_skew`. #[serde(default)] pub version_skew_browsers: Vec, } #[cfg(test)] mod status_compat_tests { use super::*; #[test] fn status_result_deserializes_legacy_version_skew_entries_without_protocol_fields() { let status: StatusResult = serde_json::from_value(serde_json::json!({ "daemon_version": "0.1.0", "protocol_version": "1.0", "pid": 42, "uptime_secs": 7, "ws_port": 52700, "sock_path": "/tmp/bsk.sock", "browsers": [], "sessions": [], "version_skew_browsers": [{ "instance_id": "legacy-browser", "browser_name": "chrome", "label": "Legacy", "server_version": "0.1.0", "client_version": "0.0.9" }] })) .expect("new CLI must tolerate old daemon skew payloads"); let skew = status .version_skew_browsers .first() .expect("legacy skew entry should deserialize"); assert_eq!(skew.server_protocol_version, ""); assert_eq!(skew.client_protocol_version, ""); } }