use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: &str = "1.1";
pub const MIN_PROTOCOL_VERSION: &str = "1.0";
pub const SUPPORTED_VERSIONS: &[&str] = &["1.1", "1.0"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolCapability {
Streaming,
Tools,
Presence,
Compression,
Attachments,
Priority,
Telemetry,
DeviceAllowlist,
PermissionRelay,
}
impl ProtocolCapability {
pub fn all_supported() -> Vec<Self> {
vec![
Self::Streaming,
Self::Tools,
Self::Attachments,
Self::Priority,
Self::DeviceAllowlist,
Self::PermissionRelay,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum CommandPriority {
Critical = 0,
High = 1,
#[default]
Normal = 2,
Low = 3,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub backoff_multiplier: f32,
#[serde(default = "default_initial_delay")]
pub initial_delay_ms: u64,
}
fn default_initial_delay() -> u64 {
100
}
fn default_permission_timeout() -> u32 {
60
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
backoff_multiplier: 2.0,
initial_delay_ms: 100,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrioritizedCommand {
pub command: BackendCommand,
#[serde(default)]
pub priority: CommandPriority,
#[serde(skip_serializing_if = "Option::is_none")]
pub deadline_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_policy: Option<RetryPolicy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtocolHello {
pub supported_versions: Vec<String>,
pub preferred_version: String,
pub capabilities: Vec<ProtocolCapability>,
}
impl Default for ProtocolHello {
fn default() -> Self {
Self {
supported_versions: SUPPORTED_VERSIONS.iter().map(|s| s.to_string()).collect(),
preferred_version: PROTOCOL_VERSION.to_string(),
capabilities: ProtocolCapability::all_supported(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtocolAccept {
pub selected_version: String,
pub enabled_capabilities: Vec<ProtocolCapability>,
}
impl Default for ProtocolAccept {
fn default() -> Self {
Self {
selected_version: PROTOCOL_VERSION.to_string(),
enabled_capabilities: vec![ProtocolCapability::Streaming, ProtocolCapability::Tools],
}
}
}
#[derive(Debug, Clone)]
pub struct NegotiatedProtocol {
pub version: String,
pub capabilities: Vec<ProtocolCapability>,
}
impl NegotiatedProtocol {
pub fn has_capability(&self, cap: ProtocolCapability) -> bool {
self.capabilities.contains(&cap)
}
pub fn from_accept(accept: ProtocolAccept) -> Self {
Self {
version: accept.selected_version,
capabilities: accept.enabled_capabilities,
}
}
}
impl Default for NegotiatedProtocol {
fn default() -> Self {
Self {
version: PROTOCOL_VERSION.to_string(),
capabilities: vec![ProtocolCapability::Streaming, ProtocolCapability::Tools],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RemoteMessage {
Register {
api_key: String,
hostname: String,
os: String,
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
protocol: Option<ProtocolHello>,
#[serde(skip_serializing_if = "Option::is_none")]
device_fingerprint: Option<String>,
},
Heartbeat {
session_token: String,
agents: Vec<RemoteAgentInfo>,
system_load: f32,
},
CommandResult {
command_id: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
AgentEvent {
event_type: AgentEventType,
agent_id: String,
data: serde_json::Value,
},
AgentStream {
agent_id: String,
chunk_type: StreamChunkType,
content: String,
},
Pong {
timestamp: i64,
},
PermissionRequest {
request_id: String,
agent_id: String,
tool_name: String,
action: String,
#[serde(default)]
details: serde_json::Value,
#[serde(default = "default_permission_timeout")]
timeout_secs: u32,
},
AttachmentReceived {
attachment_id: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BackendCommand {
Authenticated {
session_token: String,
user_id: String,
refresh_interval_secs: u32,
#[serde(skip_serializing_if = "Option::is_none")]
protocol: Option<ProtocolAccept>,
#[serde(skip_serializing_if = "Option::is_none")]
device_status: Option<DeviceStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
org_policies: Option<OrgPolicies>,
},
SendInput {
command_id: String,
agent_id: String,
content: String,
},
SlashCommand {
command_id: String,
agent_id: String,
command: String,
args: Vec<String>,
},
CancelOperation {
command_id: String,
agent_id: String,
},
Subscribe {
agent_id: String,
},
Unsubscribe {
agent_id: String,
},
SpawnAgent {
command_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
working_directory: Option<String>,
},
RequestSync,
Ping {
timestamp: i64,
},
Disconnect {
reason: String,
},
AuthenticationFailed {
error: String,
},
AttachmentUpload {
command_id: String,
agent_id: String,
attachment_id: String,
filename: String,
mime_type: String,
size: u64,
compressed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
compression_algorithm: Option<CompressionAlgorithm>,
chunks_total: u32,
},
AttachmentChunk {
attachment_id: String,
chunk_index: u32,
data: String,
is_final: bool,
},
PermissionResponse {
request_id: String,
approved: bool,
#[serde(default)]
remember_for_session: bool,
#[serde(default)]
always_allow: bool,
},
AttachmentComplete {
attachment_id: String,
checksum: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeviceStatus {
Allowed,
PendingApproval,
Blocked,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OrgPolicies {
#[serde(default)]
pub blocked_tools: Vec<String>,
#[serde(default)]
pub permission_relay_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_allowlist_mode: Option<String>,
#[serde(default)]
pub audit_all_commands: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompressionAlgorithm {
Zstd,
Gzip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteAgentInfo {
pub session_id: String,
pub model: String,
pub is_busy: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub working_directory: String,
pub message_count: usize,
pub last_activity: i64,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentEventType {
Spawned,
Exited,
Busy,
Idle,
StateChanged,
ViewerConnected,
ViewerDisconnected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamChunkType {
Text,
Thinking,
ToolCall,
ToolResult,
Error,
System,
Complete,
History,
UserInput,
}
pub fn compute_device_fingerprint() -> String {
use sha2::{Digest, Sha256};
let machine_id = get_machine_id().unwrap_or_default();
let hostname = gethostname::gethostname().to_string_lossy().to_string();
let os = std::env::consts::OS;
let mut hasher = Sha256::new();
hasher.update(machine_id.as_bytes());
hasher.update(hostname.as_bytes());
hasher.update(os.as_bytes());
hex::encode(hasher.finalize())
}
fn get_machine_id() -> Option<String> {
#[cfg(target_os = "linux")]
{
std::fs::read_to_string("/etc/machine-id")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
.ok()
.and_then(|output| {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("IOPlatformUUID") {
return line
.split('=')
.nth(1)
.map(|s| s.trim().trim_matches('"').to_string());
}
}
None
})
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Cryptography",
"/v",
"MachineGuid",
])
.output()
.ok()
.and_then(|output| {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("MachineGuid") {
return line.split_whitespace().last().map(|s| s.to_string());
}
}
None
})
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_remote_message_serialization() {
let msg = RemoteMessage::Register {
api_key: "bw_prod_test123".to_string(),
hostname: "my-laptop".to_string(),
os: "linux".to_string(),
version: "0.7.0".to_string(),
protocol: None,
device_fingerprint: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"register\""));
assert!(json.contains("\"api_key\":\"bw_prod_test123\""));
}
#[test]
fn test_remote_message_with_protocol() {
let msg = RemoteMessage::Register {
api_key: "bw_prod_test123".to_string(),
hostname: "my-laptop".to_string(),
os: "linux".to_string(),
version: "0.7.0".to_string(),
protocol: Some(ProtocolHello::default()),
device_fingerprint: Some("abc123def456".to_string()),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"protocol\""));
assert!(json.contains("\"preferred_version\":\"1.1\""));
assert!(json.contains("\"streaming\""));
}
#[test]
fn test_backend_command_deserialization() {
let json = r#"{"type":"authenticated","session_token":"abc123","user_id":"user-456","refresh_interval_secs":30}"#;
let cmd: BackendCommand = serde_json::from_str(json).unwrap();
match cmd {
BackendCommand::Authenticated {
session_token,
user_id,
refresh_interval_secs,
protocol,
device_status,
org_policies,
} => {
assert_eq!(session_token, "abc123");
assert_eq!(user_id, "user-456");
assert_eq!(refresh_interval_secs, 30);
assert!(protocol.is_none());
assert!(device_status.is_none());
assert!(org_policies.is_none());
}
_ => panic!("Expected Authenticated command"),
}
}
#[test]
fn test_backend_command_with_protocol() {
let json = r#"{"type":"authenticated","session_token":"abc123","user_id":"user-456","refresh_interval_secs":30,"protocol":{"selected_version":"1.1","enabled_capabilities":["streaming","tools"]}}"#;
let cmd: BackendCommand = serde_json::from_str(json).unwrap();
match cmd {
BackendCommand::Authenticated { protocol, .. } => {
let proto = protocol.expect("Expected protocol");
assert_eq!(proto.selected_version, "1.1");
assert!(
proto
.enabled_capabilities
.contains(&ProtocolCapability::Streaming)
);
assert!(
proto
.enabled_capabilities
.contains(&ProtocolCapability::Tools)
);
}
_ => panic!("Expected Authenticated command"),
}
}
#[test]
fn test_protocol_capability_serialization() {
let cap = ProtocolCapability::Streaming;
let json = serde_json::to_string(&cap).unwrap();
assert_eq!(json, "\"streaming\"");
let cap: ProtocolCapability = serde_json::from_str("\"attachments\"").unwrap();
assert_eq!(cap, ProtocolCapability::Attachments);
}
#[test]
fn test_negotiated_protocol() {
let accept = ProtocolAccept {
selected_version: "1.1".to_string(),
enabled_capabilities: vec![
ProtocolCapability::Streaming,
ProtocolCapability::Compression,
],
};
let negotiated = NegotiatedProtocol::from_accept(accept);
assert!(negotiated.has_capability(ProtocolCapability::Streaming));
assert!(negotiated.has_capability(ProtocolCapability::Compression));
assert!(!negotiated.has_capability(ProtocolCapability::Attachments));
}
#[test]
fn test_remote_agent_info() {
let info = RemoteAgentInfo {
session_id: "agent-123".to_string(),
model: "claude-3-5-sonnet".to_string(),
is_busy: false,
parent_id: None,
working_directory: "/home/user/project".to_string(),
message_count: 5,
last_activity: 1700000000,
status: "idle".to_string(),
name: Some("main-agent".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"session_id\":\"agent-123\""));
assert!(json.contains("\"name\":\"main-agent\""));
}
}