use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::sync::broadcast;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response, ErrorResponse};
use tokio_tungstenite::tungstenite::protocol::Message;
use tokio_tungstenite::tungstenite::http::StatusCode;
use tracing::{debug, error, info, warn};
use crate::agent_pool::AgentPool;
use crate::common_config::SlashCommandConfig;
use crate::rate_limiter::RateLimiter;
use crate::tls::TlsConfig;
use crate::pairing::{PairingManager, PairingError, PairingErrorResponse};
use crate::push::PushRelayClient;
#[derive(Debug, Clone)]
pub struct WebhookTarget {
pub workspace_id: String,
pub trigger_id: String,
pub trigger_name: String,
pub rate_limit_per_minute: u32,
pub hmac_secret: Option<String>,
pub accepted_content_types: Vec<String>,
}
pub type WebhookResolverFn =
Arc<dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<WebhookTarget>> + Send>> + Send + Sync>;
struct TriggerRateLimiter {
windows: HashMap<String, Vec<Instant>>,
}
impl TriggerRateLimiter {
fn new() -> Self {
Self { windows: HashMap::new() }
}
fn check_and_record(&mut self, token: &str, limit_per_minute: u32) -> bool {
if limit_per_minute == 0 {
return true; }
let now = Instant::now();
let window = Duration::from_secs(60);
let stamps = self.windows.entry(token.to_string()).or_default();
stamps.retain(|t| now.duration_since(*t) < window);
if stamps.len() >= limit_per_minute as usize {
return false;
}
stamps.push(now);
true
}
}
#[derive(Clone)]
pub enum AgentHandle {
Command(String),
InProcess {
stdin_tx: mpsc::Sender<Vec<u8>>,
stdout_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<Vec<u8>>>>,
},
}
pub struct StdioBridge {
agent_handle: AgentHandle,
port: u16,
bind_addr: String,
auth_token: Option<String>,
rate_limiter: Arc<RateLimiter>,
tls_config: Option<Arc<TlsConfig>>,
pairing_manager: Option<Arc<PairingManager>>,
agent_pool: Option<Arc<tokio::sync::RwLock<AgentPool>>>,
push_relay: Option<Arc<PushRelayClient>>,
webhook_resolver: Option<WebhookResolverFn>,
webhook_rate_limiter: Arc<Mutex<TriggerRateLimiter>>,
external_tls: bool,
working_dir: PathBuf,
slash_commands: Arc<Vec<SlashCommandConfig>>,
}
impl StdioBridge {
pub fn new(agent_command: String, port: u16) -> Self {
Self {
agent_handle: AgentHandle::Command(agent_command),
port,
bind_addr: "0.0.0.0".to_string(),
auth_token: None,
rate_limiter: Arc::new(RateLimiter::new(10, 30)),
tls_config: None,
pairing_manager: None,
agent_pool: None,
push_relay: None,
webhook_resolver: None,
webhook_rate_limiter: Arc::new(Mutex::new(TriggerRateLimiter::new())),
external_tls: false,
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
slash_commands: Arc::new(Vec::new()),
}
}
pub fn with_slash_commands(mut self, commands: Vec<SlashCommandConfig>) -> Self {
self.slash_commands = Arc::new(commands);
self
}
pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
self.working_dir = dir;
self
}
pub fn with_external_tls(mut self) -> Self {
self.external_tls = true;
self
}
pub fn with_agent_handle(mut self, handle: AgentHandle) -> Self {
self.agent_handle = handle;
self
}
pub fn with_bind_addr(mut self, addr: String) -> Self {
self.bind_addr = addr;
self
}
pub fn with_auth_token(mut self, token: Option<String>) -> Self {
self.auth_token = token;
self
}
pub fn with_rate_limits(mut self, max_connections_per_ip: usize, max_attempts_per_minute: usize) -> Self {
self.rate_limiter = Arc::new(RateLimiter::new(max_connections_per_ip, max_attempts_per_minute));
self
}
pub fn with_tls(mut self, tls_config: TlsConfig) -> Self {
self.tls_config = Some(Arc::new(tls_config));
self
}
pub fn with_pairing(mut self, pairing_manager: PairingManager) -> Self {
self.pairing_manager = Some(Arc::new(pairing_manager));
self
}
pub fn with_agent_pool(mut self, pool: Arc<tokio::sync::RwLock<AgentPool>>) -> Self {
self.agent_pool = Some(pool);
self
}
pub fn with_push_relay(mut self, client: PushRelayClient) -> Self {
self.push_relay = Some(Arc::new(client));
self
}
pub fn with_webhook_resolver(mut self, resolver: WebhookResolverFn) -> Self {
self.webhook_resolver = Some(resolver);
self
}
#[allow(dead_code)]
pub fn pairing_manager(&self) -> Option<&Arc<PairingManager>> {
self.pairing_manager.as_ref()
}
pub async fn start(&self) -> Result<()> {
let addr = format!("{}:{}", self.bind_addr, self.port);
let listener = TcpListener::bind(&addr)
.await
.context(format!("Failed to bind to {}", addr))?;
let protocol = if self.tls_config.is_some() { "wss" } else { "ws" };
info!("✅ WebSocket server listening on {} ({}://{})", addr, protocol, addr);
if self.tls_config.is_some() {
info!("🔒 TLS enabled");
} else if self.external_tls {
info!("🔒 TLS handled by external proxy (Tailscale / Cloudflare)");
} else {
warn!("⚠️ TLS disabled - connections are not encrypted!");
}
if self.auth_token.is_some() {
info!("🔐 Authentication required for connections");
} else {
warn!("⚠️ Authentication disabled - connections are not secured!");
}
if self.pairing_manager.is_some() {
info!("🔗 Pairing endpoint available at /pair/local, /pair/tailscale, /pair/cloudflare");
}
info!("🤖 Ready to accept mobile connections...");
let auth_token = Arc::new(self.auth_token.clone());
let rate_limiter = Arc::clone(&self.rate_limiter);
let tls_config = self.tls_config.clone();
let pairing_manager = self.pairing_manager.clone();
let webhook_resolver = self.webhook_resolver.clone();
let webhook_rate_limiter = Arc::clone(&self.webhook_rate_limiter);
loop {
match listener.accept().await {
Ok((stream, addr)) => {
let client_ip = addr.ip();
if let Err(e) = rate_limiter.check_connection(client_ip).await {
warn!("🚫 Rate limit exceeded for {}: {}", client_ip, e);
continue;
}
info!("📱 New connection from: {}", addr);
let agent_handle = self.agent_handle.clone();
let auth_token = Arc::clone(&auth_token);
let rate_limiter = Arc::clone(&rate_limiter);
let tls_config = tls_config.clone();
let pairing_manager = pairing_manager.clone();
let agent_pool = self.agent_pool.clone();
let push_relay = self.push_relay.clone();
let webhook_resolver = webhook_resolver.clone();
let webhook_rate_limiter = Arc::clone(&webhook_rate_limiter);
let client_ip_str = addr.ip().to_string();
let working_dir = self.working_dir.clone();
let slash_commands = Arc::clone(&self.slash_commands);
tokio::spawn(async move {
rate_limiter.add_connection(client_ip).await;
let result = if let Some(tls) = tls_config {
match tls.acceptor.accept(stream).await {
Ok(tls_stream) => {
handle_connection_generic(tls_stream, agent_handle, auth_token, pairing_manager, agent_pool, push_relay, webhook_resolver, webhook_rate_limiter, client_ip_str, working_dir, slash_commands).await
}
Err(e) => {
warn!("🚫 TLS handshake failed: {}", e);
Err(anyhow::anyhow!("TLS handshake failed: {}", e))
}
}
} else {
handle_connection_generic(stream, agent_handle, auth_token, pairing_manager, agent_pool, push_relay, webhook_resolver, webhook_rate_limiter, client_ip_str, working_dir, slash_commands).await
};
rate_limiter.remove_connection(client_ip).await;
if let Err(e) = result {
error!("Connection error: {}", e);
}
});
}
Err(e) => {
error!("Failed to accept connection: {}", e);
}
}
}
}
}
async fn handle_connection_generic<S>(
mut stream: S,
agent_handle: AgentHandle,
auth_token: Arc<Option<String>>,
pairing_manager: Option<Arc<PairingManager>>,
agent_pool: Option<Arc<tokio::sync::RwLock<AgentPool>>>,
push_relay: Option<Arc<PushRelayClient>>,
webhook_resolver: Option<WebhookResolverFn>,
webhook_rate_limiter: Arc<Mutex<TriggerRateLimiter>>,
client_ip: String,
working_dir: PathBuf,
slash_commands: Arc<Vec<SlashCommandConfig>>,
) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut buffer = vec![0u8; 8192];
let n = stream.read(&mut buffer).await.context("Failed to read request")?;
let request_data = &buffer[..n];
let request_str = String::from_utf8_lossy(request_data);
let first_line = request_str.lines().next().unwrap_or("");
if (first_line.contains("/pair/local") || first_line.contains("/pair/cloudflare") || first_line.contains("/pair/tailscale")) && first_line.starts_with("GET") {
info!("🔗 Pairing request received");
return handle_pairing_request(&mut stream, &request_str, pairing_manager).await;
}
if first_line.starts_with("POST") && first_line.contains("/webhook/") {
info!("🪝 Webhook request received");
return handle_webhook_request(
&mut stream,
request_data,
&request_str,
&agent_handle,
webhook_resolver,
webhook_rate_limiter,
client_ip,
)
.await;
}
let lower = request_str.to_ascii_lowercase();
let request_bytes = if lower.contains("upgrade: websocket") && !lower.contains("connection: upgrade") {
let mut patched = request_str.to_string();
if let Some(pos) = patched.find("\r\n") {
patched.insert_str(pos + 2, "Connection: upgrade\r\n");
}
patched.into_bytes()
} else {
request_data.to_vec()
};
let prefixed_stream = PrefixedStream::new(request_bytes, stream);
handle_websocket_connection(prefixed_stream, agent_handle, auth_token, agent_pool, push_relay, working_dir, slash_commands).await
}
async fn handle_pairing_request<S>(
stream: &mut S,
request: &str,
pairing_manager: Option<Arc<PairingManager>>,
) -> Result<()>
where
S: AsyncWrite + Unpin,
{
let code = request
.lines()
.next()
.and_then(|line| {
let path_part = line.split_whitespace().nth(1)?;
let query = path_part.split('?').nth(1)?;
query
.split('&')
.find(|p| p.starts_with("code="))
.map(|p| p[5..].to_string())
});
let Some(code) = code else {
let response = create_http_response(400, "Bad Request", r#"{"error":"missing_code","message":"Missing 'code' query parameter"}"#);
stream.write_all(response.as_bytes()).await?;
return Ok(());
};
let Some(manager) = pairing_manager else {
let response = create_http_response(503, "Service Unavailable", r#"{"error":"pairing_disabled","message":"Pairing is not enabled on this bridge"}"#);
stream.write_all(response.as_bytes()).await?;
return Ok(());
};
match manager.validate(&code) {
Ok(pairing_response) => {
info!("✅ Pairing successful");
let json = serde_json::to_string(&pairing_response).unwrap_or_default();
let response = create_http_response(200, "OK", &json);
stream.write_all(response.as_bytes()).await?;
}
Err(PairingError::RateLimited) => {
warn!("🚫 Pairing rate limited");
let json = serde_json::to_string(&PairingErrorResponse::rate_limited()).unwrap_or_default();
let response = create_http_response(429, "Too Many Requests", &json);
stream.write_all(response.as_bytes()).await?;
}
Err(_) => {
warn!("🚫 Invalid pairing code");
let json = serde_json::to_string(&PairingErrorResponse::invalid_code()).unwrap_or_default();
let response = create_http_response(401, "Unauthorized", &json);
stream.write_all(response.as_bytes()).await?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_webhook_request<S>(
stream: &mut S,
raw_data: &[u8],
headers_str: &str,
agent_handle: &AgentHandle,
resolver: Option<WebhookResolverFn>,
rate_limiter: Arc<Mutex<TriggerRateLimiter>>,
client_ip: String,
) -> Result<()>
where
S: AsyncWrite + AsyncRead + Unpin,
{
let token = {
let line = headers_str.lines().next().unwrap_or("");
let path = line.split_whitespace().nth(1).unwrap_or("");
let stripped = path.trim_start_matches('/');
stripped
.strip_prefix("webhook/")
.map(|t| t.split('?').next().unwrap_or(t).to_string())
.unwrap_or_default()
};
if token.is_empty() {
let resp = create_http_response(400, "Bad Request", r#"{"error":"missing_token"}"#);
stream.write_all(resp.as_bytes()).await?;
return Ok(());
}
let Some(ref resolver_fn) = resolver else {
let resp = create_http_response(
503,
"Service Unavailable",
r#"{"error":"webhooks_not_configured"}"#,
);
stream.write_all(resp.as_bytes()).await?;
return Ok(());
};
let target = resolver_fn(token.clone()).await;
let Some(target) = target else {
warn!(token = %&token[..token.len().min(12)], "webhook: unknown or disabled token");
let resp = create_http_response(404, "Not Found", r#"{"error":"not_found"}"#);
stream.write_all(resp.as_bytes()).await?;
return Ok(());
};
if target.rate_limit_per_minute > 0 {
let allowed = rate_limiter
.lock()
.await
.check_and_record(&token, target.rate_limit_per_minute);
if !allowed {
warn!(trigger = %target.trigger_id, "webhook: rate limit exceeded");
let resp = create_http_response(
429,
"Too Many Requests",
r#"{"error":"rate_limited","retry_after":60}"#,
);
stream.write_all(resp.as_bytes()).await?;
return Ok(());
}
}
let header_end = raw_data
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|p| p + 4)
.unwrap_or(raw_data.len());
let already_read = &raw_data[header_end..];
let content_length: usize = headers_str
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("content-length:"))
.and_then(|l| l.splitn(2, ':').nth(1))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
const MAX_PAYLOAD: usize = 256 * 1024;
if content_length > MAX_PAYLOAD {
let resp = create_http_response(413, "Payload Too Large", r#"{"error":"payload_too_large"}"#);
stream.write_all(resp.as_bytes()).await?;
return Ok(());
}
let mut body = already_read.to_vec();
while body.len() < content_length {
let remaining = content_length - body.len();
let read_size = remaining.min(8192);
let mut chunk = vec![0u8; read_size];
let n = stream.read(&mut chunk).await?;
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
}
let content_type = headers_str
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("content-type:"))
.and_then(|l| l.splitn(2, ':').nth(1))
.map(|v| v.trim().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let mut event_headers: HashMap<String, String> = HashMap::new();
for line in headers_str.lines().skip(1) {
if line.is_empty() {
break;
}
if let Some((k, v)) = line.splitn(2, ':').collect::<Vec<_>>().as_slice().get(0..2).and_then(|s| Some((s[0], s[1]))) {
let key_lower = k.trim().to_ascii_lowercase();
if key_lower.starts_with("x-")
|| key_lower == "content-type"
|| key_lower == "user-agent"
{
event_headers.insert(k.trim().to_string(), v.trim().to_string());
}
}
}
if let Some(ref secret) = target.hmac_secret {
if !secret.is_empty() {
let sig_header = event_headers
.get("X-Hub-Signature-256")
.or_else(|| event_headers.get("X-Signature"))
.map(|s| s.as_str())
.unwrap_or("");
if sig_header.is_empty() || !verify_hmac_sha256(secret, &body, sig_header) {
warn!(trigger = %target.trigger_id, "webhook: HMAC verification failed");
let resp =
create_http_response(401, "Unauthorized", r#"{"error":"invalid_signature"}"#);
stream.write_all(resp.as_bytes()).await?;
return Ok(());
}
}
}
let payload = format_payload(&body, &content_type);
let received_at = chrono::Utc::now();
let run_id = received_at.format("%Y-%m-%dT%H-%M-%SZ").to_string();
let notification = serde_json::json!({
"jsonrpc": "2.0",
"method": "triggers/execute",
"params": {
"trigger_id": target.trigger_id,
"workspace_id": target.workspace_id,
"payload": payload,
"content_type": content_type,
"headers": event_headers,
"received_at": received_at.to_rfc3339(),
"source_ip": client_ip,
}
});
let notification_bytes = {
let mut bytes = serde_json::to_vec(¬ification).unwrap_or_default();
bytes.push(b'\n');
bytes
};
match agent_handle {
AgentHandle::InProcess { stdin_tx, .. } => {
if let Err(e) = stdin_tx.send(notification_bytes).await {
error!(trigger = %target.trigger_id, err = %e, "failed to send triggers/execute to agent");
} else {
info!(trigger = %target.trigger_id, workspace = %target.workspace_id, "triggers/execute sent to agent");
}
}
AgentHandle::Command(_) => {
warn!("webhook received but agent is in Command mode — webhooks require InProcess (serve) mode");
}
}
let response_body = serde_json::json!({
"status": "accepted",
"run_id": run_id,
})
.to_string();
let resp = create_http_response(200, "OK", &response_body);
stream.write_all(resp.as_bytes()).await?;
Ok(())
}
fn verify_hmac_sha256(secret: &str, body: &[u8], signature: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let expected_hex = signature
.strip_prefix("sha256=")
.unwrap_or(signature);
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let result = mac.finalize().into_bytes();
let result_hex = hex::encode(result);
result_hex == expected_hex
}
fn format_payload(body: &[u8], content_type: &str) -> String {
let ct = content_type.to_ascii_lowercase();
if ct.contains("application/json") {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) {
return serde_json::to_string_pretty(&v).unwrap_or_else(|_| {
String::from_utf8_lossy(body).into_owned()
});
}
} else if ct.contains("application/x-www-form-urlencoded") {
return String::from_utf8_lossy(body)
.split('&')
.map(|pair| {
let decoded = pair.replace('+', " ");
let mut parts = decoded.splitn(2, '=');
let k = parts.next().unwrap_or("");
let v = parts.next().unwrap_or("");
format!("{}: {}", k, v)
})
.collect::<Vec<_>>()
.join("\n");
}
String::from_utf8_lossy(body).into_owned()
}
fn create_http_response(status_code: u16, status_text: &str, body: &str) -> String {
format!(
"HTTP/1.1 {} {}\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n\
{}",
status_code,
status_text,
body.len(),
body
)
}
struct PrefixedStream<S> {
prefix: Vec<u8>,
prefix_pos: usize,
inner: S,
}
impl<S> PrefixedStream<S> {
fn new(prefix: Vec<u8>, inner: S) -> Self {
Self {
prefix,
prefix_pos: 0,
inner,
}
}
}
impl<S: AsyncRead + Unpin> AsyncRead for PrefixedStream<S> {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
if self.prefix_pos < self.prefix.len() {
let remaining = &self.prefix[self.prefix_pos..];
let to_copy = std::cmp::min(remaining.len(), buf.remaining());
buf.put_slice(&remaining[..to_copy]);
self.prefix_pos += to_copy;
return std::task::Poll::Ready(Ok(()));
}
std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl<S: AsyncWrite + Unpin> AsyncWrite for PrefixedStream<S> {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
std::pin::Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
async fn handle_websocket_connection<S>(stream: S, agent_handle: AgentHandle, auth_token: Arc<Option<String>>, agent_pool: Option<Arc<tokio::sync::RwLock<AgentPool>>>, push_relay: Option<Arc<PushRelayClient>>, working_dir: PathBuf, slash_commands: Arc<Vec<SlashCommandConfig>>) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let auth_token_for_callback = Arc::clone(&auth_token);
let extracted_token = Arc::new(tokio::sync::Mutex::new(String::new()));
let extracted_token_clone = Arc::clone(&extracted_token);
let callback = move |req: &Request, response: Response| -> std::result::Result<Response, ErrorResponse> {
if let Some(expected_token) = auth_token_for_callback.as_ref() {
let header_token = req.headers()
.get("X-Bridge-Token")
.and_then(|v| v.to_str().ok())
.map(|t| t.to_string());
let token_valid = header_token.as_deref()
.map(|t| t == expected_token)
.unwrap_or(false);
let query_token = if !token_valid {
req.uri().query()
.and_then(|q| {
q.split('&')
.find(|p| p.starts_with("token="))
.map(|p| p[6..].to_string())
})
} else {
None
};
let query_token_valid = query_token.as_deref()
.map(|t| t == expected_token)
.unwrap_or(false);
if !token_valid && !query_token_valid {
let error_response = tokio_tungstenite::tungstenite::http::Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body(Some("Unauthorized: invalid or missing auth token".into()))
.unwrap();
return Err(error_response);
}
if let Some(t) = header_token.filter(|t| t == expected_token).or(query_token.filter(|t| t == expected_token)) {
if let Ok(mut guard) = extracted_token_clone.try_lock() {
*guard = t;
}
}
}
Ok(response)
};
let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
Ok(ws) => ws,
Err(e) => {
warn!("🚫 Connection rejected: {}", e);
return Err(anyhow::anyhow!("WebSocket handshake failed: {}", e));
}
};
if auth_token.is_some() {
info!("🔓 Auth token validated");
}
info!("✅ WebSocket connection established");
let client_token = extracted_token.lock().await.clone();
if let Some(pool) = agent_pool {
if client_token.is_empty() {
warn!("Keep-alive enabled but no auth token found, falling back to legacy mode");
handle_websocket_with_handle(ws_stream, agent_handle, push_relay, working_dir).await
} else {
if let AgentHandle::Command(ref cmd) = agent_handle {
handle_websocket_pooled(ws_stream, cmd.clone(), client_token, pool, push_relay, working_dir.clone(), slash_commands).await
} else {
handle_websocket_with_handle(ws_stream, agent_handle, push_relay, working_dir).await
}
}
} else {
handle_websocket_with_handle(ws_stream, agent_handle, push_relay, working_dir).await
}
}
async fn handle_websocket_pooled<S>(
ws_stream: tokio_tungstenite::WebSocketStream<S>,
agent_command: String,
token: String,
pool: Arc<tokio::sync::RwLock<AgentPool>>,
push_relay: Option<Arc<PushRelayClient>>,
_working_dir: PathBuf,
slash_commands: Arc<Vec<SlashCommandConfig>>,
) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
let (ws_to_agent_tx, mut agent_to_ws_rx, buffered, was_reused, cached_init, cached_session) = {
let mut pool = pool.write().await;
pool.get_or_spawn(&token, &agent_command).await?
};
if was_reused {
info!("♻️ Reconnected to existing agent session");
} else {
info!("🆕 Started new agent session");
}
for msg in buffered {
debug!("📦 Replaying buffered message: {}", msg.chars().take(200).collect::<String>());
if let Err(e) = ws_sender.send(Message::Text(msg.into())).await {
error!("Failed to replay buffered message: {}", e);
}
}
if was_reused {
if let Some(ref cached) = cached_init {
info!("🔄 Intercepting initialize for session resumption");
let init_handled = handle_initialize_intercept(
&mut ws_receiver, &mut ws_sender, cached
).await;
if init_handled {
info!("✅ Initialize intercepted, session state preserved");
} else {
warn!("⚠️ First message was not initialize, proceeding normally");
}
} else {
debug!("No cached initialize response, first connection will capture it");
}
if let Some(ref cached) = cached_session {
info!("🔄 Intercepting session request for session resumption");
let session_handled = handle_create_session_intercept(
&mut ws_receiver, &mut ws_sender, cached, &slash_commands
).await;
if session_handled {
info!("✅ Session request intercepted, reusing existing session");
} else {
warn!("⚠️ Next message was not a session request, proceeding normally");
}
} else {
debug!("No cached session response, first connection will capture it");
}
}
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
let needs_init_capture = !was_reused;
let token_for_capture = token.clone();
let pool_for_capture = Arc::clone(&pool);
let pending_session_req_id: Arc<std::sync::Mutex<Option<serde_json::Value>>> =
Arc::new(std::sync::Mutex::new(None));
let pending_session_req_id_writer = Arc::clone(&pending_session_req_id);
let pending_session_req_id_reader = Arc::clone(&pending_session_req_id);
let (inject_tx, mut inject_rx) = mpsc::channel::<String>(8);
let pong_received = Arc::new(AtomicBool::new(true));
let pong_received_for_receiver = Arc::clone(&pong_received);
let ws_to_agent_tx_clone = ws_to_agent_tx.clone();
let push_relay_for_register = push_relay.clone();
let mut ws_to_agent = tokio::spawn(async move {
while let Some(msg_result) = ws_receiver.next().await {
match msg_result {
Ok(msg) => {
if msg.is_text() || msg.is_binary() {
let data = msg.into_data();
let text = String::from_utf8_lossy(&data).to_string();
debug!("📥 Received from Mobile ({} bytes): {}", text.len(),
text.chars().take(200).collect::<String>());
if let Some(ref relay) = push_relay_for_register {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
let method = v.get("method").and_then(|m| m.as_str());
if method == Some("bridge/registerPushToken") {
if let Some(params) = v.get("params") {
let platform = params.get("platform").and_then(|p| p.as_str()).unwrap_or("");
let device_token = params.get("deviceToken").and_then(|t| t.as_str()).unwrap_or("");
let bundle_id = params.get("bundleId").and_then(|b| b.as_str()).unwrap_or("");
info!("📲 Registering push token: platform={}, bundle_id={}", platform, bundle_id);
let relay = Arc::clone(relay);
let platform = platform.to_string();
let device_token = device_token.to_string();
let bundle_id = bundle_id.to_string();
tokio::spawn(async move {
if let Err(e) = relay.register_device(&platform, &device_token, Some(&bundle_id)).await {
error!("Failed to register push token: {}", e);
} else {
info!("✅ Push token registered successfully");
}
});
}
continue;
}
if method == Some("bridge/unregisterPushToken") {
if let Some(params) = v.get("params") {
let device_token = params.get("deviceToken").and_then(|t| t.as_str()).unwrap_or("");
info!("📲 Unregistering push token");
let relay = Arc::clone(relay);
let device_token = device_token.to_string();
tokio::spawn(async move {
if let Err(e) = relay.unregister_device(&device_token).await {
error!("Failed to unregister push token: {}", e);
}
});
}
continue;
}
}
}
if needs_init_capture {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
let method = v.get("method").and_then(|m| m.as_str());
if method == Some("session/load") {
if let Some(req_id) = v.get("id") {
let session_id = v.pointer("/params/sessionId")
.and_then(|s| s.as_str())
.or_else(|| v.pointer("/params/sessionId/value").and_then(|s| s.as_str()))
.unwrap_or("unknown");
info!("🔄 Returning synthetic error for session/load on fresh agent (id={}, session={})", req_id, session_id);
let error_response = serde_json::json!({
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32602,
"message": "Invalid params",
"data": format!("Session not found (fresh agent): {}", session_id)
}
});
let _ = inject_tx.send(serde_json::to_string(&error_response).unwrap_or_default()).await;
}
continue; }
if method == Some("session/new") {
if let Some(id) = v.get("id") {
info!("📋 Tracking session/new request id={}", id);
if let Ok(mut guard) = pending_session_req_id_writer.lock() {
*guard = Some(id.clone());
}
}
}
}
}
if ws_to_agent_tx_clone.send(text).await.is_err() {
error!("Failed to send to agent channel");
break;
}
debug!("✅ Forwarded to agent");
} else if msg.is_pong() {
pong_received_for_receiver.store(true, Ordering::Relaxed);
debug!("📶 Pong received from client");
} else if msg.is_close() {
info!("📱 Client closed connection");
break;
}
}
Err(e) => {
error!("WebSocket receive error: {}", e);
break;
}
}
}
debug!("WebSocket receiver task ended");
});
let shutdown_tx_clone = shutdown_tx.clone();
let token_for_buffer = token.clone();
let pool_for_buffer = Arc::clone(&pool);
let agent_to_ws = tokio::spawn(async move {
let mut init_captured = false;
let mut session_captured = false;
let mut ping_interval = tokio::time::interval(Duration::from_secs(30));
ping_interval.tick().await; loop {
tokio::select! {
result = agent_to_ws_rx.recv() => { match result {
Ok(line) => {
if needs_init_capture && !init_captured {
if is_initialize_response(&line) {
info!("📋 Captured initialize response for future reconnections");
let mut pool = pool_for_capture.write().await;
pool.cache_init_response(&token_for_capture, line.clone());
init_captured = true;
}
}
if needs_init_capture && !session_captured {
let is_session_resp = if is_create_session_response(&line) {
true
} else if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
if v.get("result").is_some() {
if let Some(resp_id) = v.get("id") {
let matches = pending_session_req_id_reader
.lock()
.map(|guard| guard.as_ref() == Some(resp_id))
.unwrap_or(false);
if matches {
info!("📋 Session response matched by request ID (id={})", resp_id);
}
matches
} else {
false
}
} else {
false
}
} else {
false
};
if is_session_resp {
info!("📋 Captured createSession response for future reconnections");
let mut pool = pool_for_capture.write().await;
pool.cache_session_response(&token_for_capture, line.clone());
session_captured = true;
}
}
if line.contains("Session not found") {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
if v.get("error").is_some() {
warn!("🗑️ Agent reported 'Session not found' — invalidating cached session");
let mut pool = pool_for_capture.write().await;
pool.clear_session_response(&token_for_capture);
}
}
}
let inject_commands = !slash_commands.is_empty()
&& is_create_session_response(&line)
&& !line.contains("\"error\"");
debug!("📤 Sending to Mobile ({} bytes): {}", line.len(),
line.chars().take(200).collect::<String>());
if let Err(e) = ws_sender.send(Message::Text(line.clone().into())).await {
debug!("Client disconnected, buffering message: {}", e);
let mut pool = pool_for_buffer.write().await;
pool.buffer_message(&token_for_buffer, line);
if let Some(ref relay) = push_relay {
let relay = Arc::clone(relay);
tokio::spawn(async move {
if let Err(e) = relay.notify("Agent").await {
debug!("Push notification failed: {}", e);
}
});
}
break;
}
if inject_commands {
if let Some(session_id) = extract_session_id_from_response(&line) {
let notification = build_available_commands_notification(
&session_id, &slash_commands,
);
info!("📋 Injecting available_commands_update for session {}", session_id);
let _ = ws_sender.send(Message::Text(notification.into())).await;
}
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("Agent-to-WS receiver lagged, skipped {} messages", n);
continue;
}
Err(broadcast::error::RecvError::Closed) => {
debug!("Agent broadcast channel closed (agent exited)");
break;
}
} } Some(injected) = inject_rx.recv() => {
debug!("📤 Sending injected response to Mobile ({} bytes)", injected.len());
if let Err(e) = ws_sender.send(Message::Text(injected.into())).await {
debug!("Client disconnected while sending injected response: {}", e);
break;
}
}
_ = ping_interval.tick() => {
if !pong_received.swap(false, Ordering::Relaxed) {
warn!("💀 Ping timeout: no pong from client, closing dead connection");
break;
}
debug!("📶 Sending WebSocket ping to client");
if let Err(e) = ws_sender.send(Message::Ping(vec![].into())).await {
debug!("Ping send failed (client disconnected): {}", e);
break;
}
}
} }
debug!("Agent-to-WS forwarder task ended");
let _ = shutdown_tx_clone.send(()).await;
});
tokio::select! {
_ = &mut ws_to_agent => {
debug!("WS-to-agent task completed first");
}
_ = shutdown_rx.recv() => {
debug!("Agent-to-WS task completed first");
}
}
info!("💤 Client disconnected, agent stays alive in pool");
ws_to_agent.abort();
agent_to_ws.abort();
{
let mut pool = pool.write().await;
pool.mark_disconnected(&token);
}
Ok(())
}
fn is_initialize_response(msg: &str) -> bool {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg) {
v.get("result").is_some()
&& (v["result"].get("capabilities").is_some()
|| v["result"].get("serverInfo").is_some()
|| v["result"].get("agentInfo").is_some()
|| v["result"].get("agentCapabilities").is_some()
|| v["result"].get("protocolVersion").is_some())
} else {
false
}
}
fn is_create_session_response(msg: &str) -> bool {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg) {
if let Some(result) = v.get("result") {
result.get("sessionId").is_some()
} else {
false
}
} else {
false
}
}
fn extract_session_id_from_response(response: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(response)
.ok()
.and_then(|v| {
v.get("result")
.and_then(|r| r.get("sessionId"))
.and_then(|s| s.as_str())
.map(String::from)
})
}
fn build_available_commands_notification(
session_id: &str,
commands: &[SlashCommandConfig],
) -> String {
let cmds: Vec<serde_json::Value> = commands
.iter()
.map(|c| {
let mut obj = serde_json::json!({
"name": c.name,
"description": c.description,
});
if let Some(ref hint) = c.input_hint {
obj["input"] = serde_json::json!({ "hint": hint });
}
obj
})
.collect();
serde_json::to_string(&serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "available_commands_update",
"availableCommands": cmds
}
}
}))
.unwrap_or_default()
}
async fn handle_create_session_intercept<S>(
ws_receiver: &mut futures_util::stream::SplitStream<tokio_tungstenite::WebSocketStream<S>>,
ws_sender: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<S>, Message>,
cached_response: &str,
slash_commands: &[SlashCommandConfig],
) -> bool
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut request: serde_json::Value;
let max_skip = 5; let mut skipped = 0;
loop {
let msg = match tokio::time::timeout(
std::time::Duration::from_secs(30),
ws_receiver.next(),
).await {
Ok(Some(Ok(msg))) if msg.is_text() || msg.is_binary() => {
String::from_utf8_lossy(&msg.into_data()).to_string()
}
_ => return false,
};
request = match serde_json::from_str(&msg) {
Ok(v) => v,
Err(_) => return false,
};
let method = request.get("method").and_then(|m| m.as_str());
if method == Some("session/new") || method == Some("session/load") {
break; }
if method.is_some() && request.get("id").is_none() {
info!("📨 Skipping notification during session intercept: {:?}", method);
skipped += 1;
if skipped >= max_skip {
warn!("⚠️ Too many notifications before session request, giving up");
return false;
}
continue;
}
if method == Some("initialize") {
if let Some(req_id) = request.get("id") {
info!("📨 Handling uncached initialize during session intercept (id={})", req_id);
let init_response = serde_json::json!({
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": 1,
"agentCapabilities": {},
"agentInfo": {
"name": "bridge",
"version": "1.0.0"
}
}
});
let resp_str = serde_json::to_string(&init_response).unwrap_or_default();
if let Err(e) = ws_sender.send(Message::Text(resp_str.into())).await {
error!("Failed to send synthetic initialize response: {}", e);
return false;
}
skipped += 1;
if skipped >= max_skip {
warn!("⚠️ Too many messages before session request, giving up");
return false;
}
continue;
}
}
warn!("⚠️ Message is not session/new or session/load (method={:?}, has_id={}, raw={}), cannot intercept",
method, request.get("id").is_some(),
msg.chars().take(200).collect::<String>());
return false;
}
let request_id = match request.get("id") {
Some(id) => id.clone(),
None => return false,
};
let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("unknown");
info!("🔄 Intercepting {} request (id={})", method, request_id);
let mut cached: serde_json::Value = match serde_json::from_str(cached_response) {
Ok(v) => v,
Err(e) => {
error!("Failed to parse cached session response: {}", e);
return false;
}
};
cached["id"] = request_id;
let response_str = serde_json::to_string(&cached).unwrap_or_default();
debug!("🔄 Sending cached session response ({} bytes): {}", response_str.len(),
response_str.chars().take(200).collect::<String>());
if let Err(e) = ws_sender.send(Message::Text(response_str.into())).await {
error!("Failed to send cached session response: {}", e);
return false;
}
if !slash_commands.is_empty() {
if let Some(session_id) = extract_session_id_from_response(cached_response) {
let notification = build_available_commands_notification(&session_id, slash_commands);
info!("📋 Injecting available_commands_update for cached session {}", session_id);
let _ = ws_sender.send(Message::Text(notification.into())).await;
}
}
true
}
async fn handle_initialize_intercept<S>(
ws_receiver: &mut futures_util::stream::SplitStream<tokio_tungstenite::WebSocketStream<S>>,
ws_sender: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<S>, Message>,
cached_response: &str,
) -> bool
where
S: AsyncRead + AsyncWrite + Unpin,
{
let first_msg = match tokio::time::timeout(
std::time::Duration::from_secs(30),
ws_receiver.next(),
).await {
Ok(Some(Ok(msg))) if msg.is_text() || msg.is_binary() => {
String::from_utf8_lossy(&msg.into_data()).to_string()
}
_ => return false,
};
let request: serde_json::Value = match serde_json::from_str(&first_msg) {
Ok(v) => v,
Err(_) => return false,
};
let method = request.get("method").and_then(|m| m.as_str());
if method != Some("initialize") {
debug!("First message is not initialize (method={:?}), cannot intercept", method);
return false;
}
let request_id = match request.get("id") {
Some(id) => id.clone(),
None => return false,
};
info!("🔄 Intercepting initialize request (id={})", request_id);
let mut cached: serde_json::Value = match serde_json::from_str(cached_response) {
Ok(v) => v,
Err(e) => {
error!("Failed to parse cached initialize response: {}", e);
return false;
}
};
cached["id"] = request_id;
let response_str = serde_json::to_string(&cached).unwrap_or_default();
debug!("🔄 Sending cached initialize response ({} bytes)", response_str.len());
if let Err(e) = ws_sender.send(Message::Text(response_str.into())).await {
error!("Failed to send cached initialize response: {}", e);
return false;
}
true
}
async fn handle_websocket_with_handle<S>(
ws_stream: tokio_tungstenite::WebSocketStream<S>,
agent_handle: AgentHandle,
push_relay: Option<Arc<PushRelayClient>>,
working_dir: PathBuf,
) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
match agent_handle {
AgentHandle::Command(cmd) => handle_websocket_legacy(ws_stream, cmd, push_relay, working_dir).await,
AgentHandle::InProcess { stdin_tx, stdout_rx } => {
handle_websocket_inprocess(ws_stream, stdin_tx, stdout_rx).await
}
}
}
async fn handle_websocket_inprocess<S>(
ws_stream: tokio_tungstenite::WebSocketStream<S>,
stdin_tx: mpsc::Sender<Vec<u8>>,
stdout_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<Vec<u8>>>>,
) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
let (agent_stop_tx, mut agent_stop_rx) = mpsc::channel::<()>(1);
let shutdown_tx_ws = shutdown_tx.clone();
let ws_to_agent = tokio::spawn(async move {
while let Some(msg_result) = ws_receiver.next().await {
match msg_result {
Ok(msg) if msg.is_text() || msg.is_binary() => {
let mut data = msg.into_data().to_vec();
data.push(b'\n');
debug!("📥 WS→agent ({} bytes)", data.len());
if stdin_tx.send(data).await.is_err() {
break;
}
}
Ok(msg) if msg.is_close() => {
info!("📱 Client closed connection");
break;
}
Err(e) => { error!("WebSocket receive error: {}", e); break; }
_ => {}
}
}
debug!("ws_to_agent task ended");
let _ = agent_stop_tx.send(()).await;
let _ = shutdown_tx_ws.send(()).await;
});
let shutdown_tx_clone = shutdown_tx.clone();
let agent_to_ws = tokio::spawn(async move {
let mut rx = stdout_rx.lock().await;
loop {
tokio::select! {
bytes_opt = rx.recv() => {
match bytes_opt {
Some(bytes) => {
let line = String::from_utf8_lossy(&bytes).trim_end_matches('\n').to_string();
debug!("📤 agent→WS ({} bytes)", line.len());
if let Err(e) = ws_sender.send(Message::Text(line.into())).await {
let msg = e.to_string();
if msg.contains("Sending after closing") || msg.contains("connection closed") {
debug!("WebSocket closed before message could be sent (client disconnected)");
} else {
error!("Failed to send to WebSocket: {}", e);
}
break;
}
}
None => break,
}
}
_ = agent_stop_rx.recv() => {
debug!("agent_to_ws: stop signal received, releasing stdout_rx");
break;
}
}
}
let _ = shutdown_tx_clone.send(()).await;
});
shutdown_rx.recv().await;
ws_to_agent.abort();
let _ = agent_to_ws.await;
Ok(())
}
async fn handle_websocket_legacy<S>(ws_stream: tokio_tungstenite::WebSocketStream<S>, agent_command: String, _push_relay: Option<Arc<PushRelayClient>>, working_dir: PathBuf) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
let parts: Vec<&str> = agent_command.split_whitespace().collect();
if parts.is_empty() {
anyhow::bail!("Empty agent command");
}
let command = parts[0];
let args = &parts[1..];
info!("🚀 Spawning agent: {} {:?} (cwd: {})", command, args, working_dir.display());
let mut child = Command::new(command)
.args(args)
.current_dir(&working_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.context(format!("Failed to spawn agent command: {}", agent_command))?;
let stdin = child
.stdin
.take()
.context("Failed to open agent stdin")?;
let stdout = child
.stdout
.take()
.context("Failed to open agent stdout")?;
let stderr = child
.stderr
.take()
.context("Failed to open agent stderr")?;
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
let mut stdin_writer = stdin;
let ws_to_agent = tokio::spawn(async move {
while let Some(msg_result) = ws_receiver.next().await {
match msg_result {
Ok(msg) => {
if msg.is_text() || msg.is_binary() {
let raw = msg.into_data();
let data = String::from_utf8_lossy(&raw);
debug!("📥 Received from Mobile ({} bytes): {}", data.len(),
data.chars().take(200).collect::<String>());
if let Err(e) = stdin_writer.write_all(data.as_bytes()).await {
error!("Failed to write to agent stdin: {}", e);
break;
}
if let Err(e) = stdin_writer.write_all(b"\n").await {
error!("Failed to write newline to agent stdin: {}", e);
break;
}
if let Err(e) = stdin_writer.flush().await {
error!("Failed to flush agent stdin: {}", e);
break;
}
debug!("✅ Forwarded to agent");
} else if msg.is_close() {
info!("📱 Client closed connection");
break;
}
}
Err(e) => {
error!("WebSocket receive error: {}", e);
break;
}
}
}
debug!("WebSocket receiver task ended");
});
let shutdown_tx_clone = shutdown_tx.clone();
let stdout_reader = BufReader::new(stdout);
let agent_to_ws = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
info!("📖 Agent stdout reader task started");
while let Ok(Some(line)) = lines.next_line().await {
info!("📤 Agent -> Mobile ({} bytes): {}", line.len(),
line.chars().take(200).collect::<String>());
if let Err(e) = ws_sender.send(Message::Text(line.into())).await {
let msg = e.to_string();
if msg.contains("Sending after closing") || msg.contains("connection closed") {
debug!("WebSocket closed before message could be sent (client disconnected)");
} else {
error!("Failed to send to WebSocket: {}", e);
}
break;
}
info!("✅ Message sent to WebSocket successfully");
}
info!("Agent stdout reader task ended");
let _ = shutdown_tx_clone.send(()).await;
});
let stderr_reader = BufReader::new(stderr);
let stderr_logger = tokio::spawn(async move {
let mut lines = stderr_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
warn!("🤖 Agent stderr: {}", line);
}
debug!("Agent stderr reader task ended");
});
let mut child_monitor = child;
let shutdown_tx_clone = shutdown_tx.clone();
let process_monitor = tokio::spawn(async move {
match child_monitor.wait().await {
Ok(status) => {
if status.success() {
info!("🤖 Agent process exited successfully");
} else {
error!("🤖 Agent process exited with: {}", status);
}
}
Err(e) => {
error!("Failed to wait for agent process: {}", e);
}
}
let _ = shutdown_tx_clone.send(()).await;
});
shutdown_rx.recv().await;
info!("🔌 Connection closing, cleaning up...");
ws_to_agent.abort();
agent_to_ws.abort();
stderr_logger.abort();
process_monitor.abort();
Ok(())
}