mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
633 lines
24 KiB
Rust
633 lines
24 KiB
Rust
//! 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<Ordering> {
|
|
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<ProtocolParts> {
|
|
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::<u64>()`
|
|
/// 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<u64> {
|
|
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<String>")]
|
|
pub min_compatible_peer: Option<Version>,
|
|
/// Lowest peer **protocol** version this side accepts (e.g. `"1.0"`).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub min_compatible_protocol: Option<String>,
|
|
}
|
|
|
|
#[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<String>")]
|
|
pub min_compatible_peer: Option<Version>,
|
|
/// Lowest peer protocol version this daemon accepts.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub min_compatible_protocol: Option<String>,
|
|
}
|
|
|
|
#[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<i64>,
|
|
/// 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<u64>,
|
|
}
|
|
|
|
/// `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<u64>,
|
|
}
|
|
|
|
/// 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<BrowserStatusEntry>,
|
|
/// Snapshot of live sessions.
|
|
pub sessions: Vec<SessionStatusEntry>,
|
|
/// 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<VersionSkewEntry>,
|
|
}
|
|
|
|
#[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, "");
|
|
}
|
|
}
|