| 1 | //! The WebSocket listener. |
| 2 | //! |
| 3 | //! Spike scope: prove the *security-relevant* path end to end (bind, Origin, |
| 4 | //! Host, token, pairing) and echo afterwards. Attaching `tmux -C` replaces the |
| 5 | //! echo loop later and changes none of the code above it. |
| 6 | |
| 7 | use std::net::{IpAddr, Ipv4Addr, SocketAddr}; |
| 8 | use std::sync::Arc; |
| 9 | use std::time::Duration; |
| 10 | |
| 11 | use futures_util::{SinkExt, StreamExt}; |
| 12 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; |
| 13 | use tokio::net::{TcpListener, TcpStream}; |
| 14 | use tokio::sync::{mpsc, Mutex}; |
| 15 | use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response}; |
| 16 | use tokio_tungstenite::tungstenite::http; |
| 17 | use tokio_tungstenite::tungstenite::Message; |
| 18 | use tokio_tungstenite::WebSocketStream; |
| 19 | |
| 20 | use crate::auth::{self, Denied}; |
| 21 | |
| 22 | #[derive(Clone)] |
| 23 | pub struct Config { |
| 24 | pub token: String, |
| 25 | pub paired_origins: Vec<String>, |
| 26 | /// How long a client has to send its auth frame after the upgrade. |
| 27 | pub auth_timeout: Duration, |
| 28 | pub max_failures: u32, |
| 29 | pub lockout: Duration, |
| 30 | /// What we run in the pty. Fixed by the daemon, never client-supplied. |
| 31 | pub profile: crate::pty::Profile, |
| 32 | /// Session used when the client doesn't name one. |
| 33 | pub default_session: String, |
| 34 | /// When the client names no session and exactly one tmux session already |
| 35 | /// exists, attach to that one instead of `default_session`. Off when the |
| 36 | /// user pinned a session on the command line — an explicit `--session` is |
| 37 | /// a choice, not a default to be second-guessed. |
| 38 | pub adopt_sole_session: bool, |
| 39 | /// Skip the pty and echo frames back. Used by the security tests so they |
| 40 | /// exercise the auth path without spawning shells. |
| 41 | pub echo_only: bool, |
| 42 | /// TLS identity. When present the listener serves wss:// and ws:// on the |
| 43 | /// same port, chosen per-connection by sniffing the first byte. |
| 44 | pub tls: Option<tokio_rustls::TlsAcceptor>, |
| 45 | } |
| 46 | |
| 47 | impl Config { |
| 48 | pub fn new(token: impl Into<String>, paired_origins: Vec<String>) -> Self { |
| 49 | Self { |
| 50 | token: token.into(), |
| 51 | paired_origins, |
| 52 | auth_timeout: Duration::from_secs(3), |
| 53 | max_failures: 10, |
| 54 | lockout: Duration::from_secs(30), |
| 55 | profile: crate::pty::Profile::default(), |
| 56 | default_session: crate::pty::DEFAULT_SESSION.to_string(), |
| 57 | // Opt-in: a caller that hands us a profile has picked its session, |
| 58 | // and only the CLI knows whether the user picked it or we did. |
| 59 | adopt_sole_session: false, |
| 60 | echo_only: false, |
| 61 | tls: None, |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// The lone existing session to join instead of the profile's own, if |
| 66 | /// adoption is on and tmux is holding exactly one. |
| 67 | /// |
| 68 | /// Resolved per connection rather than at startup: sessions come and go |
| 69 | /// while the daemon runs, and the answer should reflect what tmux holds at |
| 70 | /// the moment the sidebar connects. `None` means "use the profile as |
| 71 | /// configured". |
| 72 | pub fn adopted_session(&self) -> Option<String> { |
| 73 | if !self.adopt_sole_session || self.profile.program != "tmux" { |
| 74 | return None; |
| 75 | } |
| 76 | crate::pty::sole_session(&self.profile.tmux_global_args()) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 81 | pub enum Event { |
| 82 | Accepted { origin: String }, |
| 83 | Rejected { |
| 84 | origin: Option<String>, |
| 85 | why: Denied, |
| 86 | /// Underlying cause, when the failure happened below our own checks |
| 87 | /// (e.g. the request never was a WebSocket upgrade). |
| 88 | detail: Option<String>, |
| 89 | }, |
| 90 | } |
| 91 | |
| 92 | struct Failures { |
| 93 | count: u32, |
| 94 | locked_until: Option<tokio::time::Instant>, |
| 95 | } |
| 96 | |
| 97 | struct State { |
| 98 | config: Config, |
| 99 | failures: Mutex<Failures>, |
| 100 | events: mpsc::UnboundedSender<Event>, |
| 101 | } |
| 102 | |
| 103 | impl State { |
| 104 | async fn locked_out(&self) -> bool { |
| 105 | let f = self.failures.lock().await; |
| 106 | match f.locked_until { |
| 107 | Some(t) => tokio::time::Instant::now() < t, |
| 108 | None => false, |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | async fn record_failure(&self) { |
| 113 | let mut f = self.failures.lock().await; |
| 114 | f.count += 1; |
| 115 | if f.count >= self.config.max_failures { |
| 116 | f.locked_until = Some(tokio::time::Instant::now() + self.config.lockout); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | async fn record_success(&self) { |
| 121 | let mut f = self.failures.lock().await; |
| 122 | f.count = 0; |
| 123 | f.locked_until = None; |
| 124 | } |
| 125 | |
| 126 | fn emit(&self, e: Event) { |
| 127 | let _ = self.events.send(e); |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | pub struct Server { |
| 132 | addr: SocketAddr, |
| 133 | events: mpsc::UnboundedReceiver<Event>, |
| 134 | _task: tokio::task::JoinHandle<()>, |
| 135 | } |
| 136 | |
| 137 | impl Server { |
| 138 | /// Bind and start accepting. |
| 139 | /// |
| 140 | /// The bind address is hardcoded to loopback and is not configurable. This |
| 141 | /// daemon hands out shell access; a `--bind` flag would be a footgun whose |
| 142 | /// only purpose is to create a remotely exploitable configuration. |
| 143 | pub async fn start(config: Config, port: u16) -> std::io::Result<Self> { |
| 144 | let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); |
| 145 | let listener = TcpListener::bind(addr).await?; |
| 146 | let addr = listener.local_addr()?; |
| 147 | |
| 148 | // Belt and braces: if this ever regresses, fail loudly at startup |
| 149 | // rather than quietly listening on the network. |
| 150 | assert!( |
| 151 | addr.ip().is_loopback(), |
| 152 | "refusing to serve on non-loopback address {addr}" |
| 153 | ); |
| 154 | |
| 155 | let (tx, rx) = mpsc::unbounded_channel(); |
| 156 | let state = Arc::new(State { |
| 157 | config, |
| 158 | failures: Mutex::new(Failures { |
| 159 | count: 0, |
| 160 | locked_until: None, |
| 161 | }), |
| 162 | events: tx, |
| 163 | }); |
| 164 | |
| 165 | let task = tokio::spawn(async move { |
| 166 | loop { |
| 167 | let Ok((stream, peer)) = listener.accept().await else { |
| 168 | continue; |
| 169 | }; |
| 170 | let state = Arc::clone(&state); |
| 171 | tokio::spawn(async move { |
| 172 | let _ = handle_conn(stream, peer, state).await; |
| 173 | }); |
| 174 | } |
| 175 | }); |
| 176 | |
| 177 | Ok(Server { |
| 178 | addr, |
| 179 | events: rx, |
| 180 | _task: task, |
| 181 | }) |
| 182 | } |
| 183 | |
| 184 | pub fn addr(&self) -> SocketAddr { |
| 185 | self.addr |
| 186 | } |
| 187 | |
| 188 | pub fn url(&self) -> String { |
| 189 | format!("ws://{}", self.addr) |
| 190 | } |
| 191 | |
| 192 | pub async fn next_event(&mut self) -> Option<Event> { |
| 193 | self.events.recv().await |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | fn deny(why: Denied) -> ErrorResponse { |
| 198 | http::Response::builder() |
| 199 | .status(why.status()) |
| 200 | .body(Some(why.reason().to_string())) |
| 201 | .expect("static response builds") |
| 202 | } |
| 203 | |
| 204 | async fn handle_conn( |
| 205 | stream: TcpStream, |
| 206 | _peer: SocketAddr, |
| 207 | state: Arc<State>, |
| 208 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { |
| 209 | if state.locked_out().await { |
| 210 | state.emit(Event::Rejected { |
| 211 | origin: None, |
| 212 | why: Denied::RateLimited, |
| 213 | detail: None, |
| 214 | }); |
| 215 | // Still perform the reject through the handshake so the client sees 429. |
| 216 | let _ = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, _| { |
| 217 | Err(deny(Denied::RateLimited)) |
| 218 | }) |
| 219 | .await; |
| 220 | return Ok(()); |
| 221 | } |
| 222 | |
| 223 | // A TLS ClientHello starts with 0x16 (handshake record). Sniffing it lets |
| 224 | // one port serve both wss:// and ws://, which matters because Firefox's |
| 225 | // HTTPS-Only Mode rewrites ws:// to wss:// and the user should not have to |
| 226 | // weaken a browser security setting to run this. |
| 227 | let mut first = [0u8; 1]; |
| 228 | let is_tls = matches!(stream.peek(&mut first).await, Ok(1) if first[0] == 0x16); |
| 229 | |
| 230 | if is_tls { |
| 231 | let Some(acceptor) = state.config.tls.clone() else { |
| 232 | state.record_failure().await; |
| 233 | state.emit(Event::Rejected { |
| 234 | origin: None, |
| 235 | why: Denied::TlsAttempted, |
| 236 | detail: Some("this daemon was started without a TLS identity".into()), |
| 237 | }); |
| 238 | return Ok(()); |
| 239 | }; |
| 240 | let tls_stream = match acceptor.accept(stream).await { |
| 241 | Ok(s) => s, |
| 242 | Err(e) => { |
| 243 | // Almost always the browser refusing our self-signed cert. |
| 244 | state.emit(Event::Rejected { |
| 245 | origin: None, |
| 246 | why: Denied::TlsHandshakeFailed, |
| 247 | detail: Some(e.to_string()), |
| 248 | }); |
| 249 | return Ok(()); |
| 250 | } |
| 251 | }; |
| 252 | return dispatch(tls_stream, state).await; |
| 253 | } |
| 254 | |
| 255 | dispatch(stream, state).await |
| 256 | } |
| 257 | |
| 258 | /// Serve one already-negotiated stream: either the certificate-trust landing |
| 259 | /// page, or the WebSocket handshake. |
| 260 | async fn dispatch<S>(mut stream: S, state: Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 261 | where |
| 262 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 263 | { |
| 264 | // Look at the request head so a browser that navigated here to accept the |
| 265 | // certificate gets an explanation instead of a protocol error. |
| 266 | let head = crate::rewind::read_head(&mut stream, 8192).await.unwrap_or_default(); |
| 267 | if !crate::rewind::is_websocket_upgrade(&head) { |
| 268 | state.emit(Event::Rejected { |
| 269 | origin: None, |
| 270 | why: Denied::NotAWebSocketUpgrade, |
| 271 | detail: Some("served the certificate-trust page".into()), |
| 272 | }); |
| 273 | let _ = stream.write_all(LANDING_PAGE.as_bytes()).await; |
| 274 | let _ = stream.flush().await; |
| 275 | return Ok(()); |
| 276 | } |
| 277 | let stream = crate::rewind::Rewind::new(head, stream); |
| 278 | handshake(stream, state).await |
| 279 | } |
| 280 | |
| 281 | const LANDING_PAGE: &str = concat!( |
| 282 | "HTTP/1.1 200 OK\r\n", |
| 283 | "Content-Type: text/html; charset=utf-8\r\n", |
| 284 | "Connection: close\r\n", |
| 285 | "Cache-Control: no-store\r\n", |
| 286 | "\r\n", |
| 287 | "<!doctype html><meta charset=utf-8><title>termbridge</title>", |
| 288 | "<style>body{font:15px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;", |
| 289 | "padding:0 1.5rem;background:#14161a;color:#d7dae0}h1{font-size:1.1rem}", |
| 290 | "code{background:#1c1f25;padding:.15em .4em;border-radius:3px;font-size:.9em}", |
| 291 | "p{color:#9aa3b2}</style>", |
| 292 | "<h1>termbridge is running</h1>", |
| 293 | "<p>If you reached this page to accept the certificate, you're done \u{2014} ", |
| 294 | "close this tab and reconnect the sidebar.</p>", |
| 295 | "<p>This endpoint serves the terminal over WebSocket only. It exposes no ", |
| 296 | "other data, and in particular it never hands out the auth token.</p>" |
| 297 | ); |
| 298 | |
| 299 | async fn handshake<S>(stream: S, state: Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 300 | where |
| 301 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 302 | { |
| 303 | let seen_origin: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None)); |
| 304 | let handshake_err: Arc<std::sync::Mutex<Option<Denied>>> = |
| 305 | Arc::new(std::sync::Mutex::new(None)); |
| 306 | |
| 307 | let paired = state.config.paired_origins.clone(); |
| 308 | let so = Arc::clone(&seen_origin); |
| 309 | let he = Arc::clone(&handshake_err); |
| 310 | |
| 311 | let ws = tokio_tungstenite::accept_hdr_async(stream, move |req: &Request, res: Response| { |
| 312 | let header = |name: &str| { |
| 313 | req.headers() |
| 314 | .get(name) |
| 315 | .and_then(|v| v.to_str().ok()) |
| 316 | .map(str::to_string) |
| 317 | }; |
| 318 | let origin = header("origin"); |
| 319 | let host = header("host"); |
| 320 | *so.lock().unwrap() = origin.clone(); |
| 321 | |
| 322 | match auth::check_handshake(origin.as_deref(), host.as_deref(), &paired) { |
| 323 | Ok(()) => Ok(res), |
| 324 | Err(why) => { |
| 325 | *he.lock().unwrap() = Some(why); |
| 326 | Err(deny(why)) |
| 327 | } |
| 328 | } |
| 329 | }) |
| 330 | .await; |
| 331 | |
| 332 | let origin = seen_origin.lock().unwrap().clone(); |
| 333 | let hs_err = *handshake_err.lock().unwrap(); |
| 334 | |
| 335 | let mut ws = match ws { |
| 336 | Ok(ws) => ws, |
| 337 | Err(e) => { |
| 338 | // If hs_err is set, we rejected it deliberately. If it is not, the |
| 339 | // handshake failed before our callback ever ran — which means the |
| 340 | // request was not a valid WebSocket upgrade at all. Reporting that |
| 341 | // as an auth problem sends people hunting for the wrong bug. |
| 342 | let (why, detail) = match hs_err { |
| 343 | Some(why) => (why, None), |
| 344 | None => (Denied::NotAWebSocketUpgrade, Some(e.to_string())), |
| 345 | }; |
| 346 | state.record_failure().await; |
| 347 | state.emit(Event::Rejected { origin, why, detail }); |
| 348 | return Ok(()); |
| 349 | } |
| 350 | }; |
| 351 | |
| 352 | // Upgrade succeeded; now the auth frame, on a deadline. |
| 353 | let first = tokio::time::timeout(state.config.auth_timeout, ws.next()).await; |
| 354 | |
| 355 | let result = match first { |
| 356 | Err(_) => Err(Denied::AuthTimeout), |
| 357 | Ok(None) => Err(Denied::AuthTimeout), |
| 358 | Ok(Some(Err(_))) => Err(Denied::MalformedAuth), |
| 359 | Ok(Some(Ok(Message::Text(t)))) => auth::check_auth_frame(&t, &state.config.token), |
| 360 | Ok(Some(Ok(_))) => Err(Denied::MalformedAuth), |
| 361 | }; |
| 362 | |
| 363 | if let Err(why) = result { |
| 364 | state.record_failure().await; |
| 365 | state.emit(Event::Rejected { |
| 366 | origin, |
| 367 | why, |
| 368 | detail: None, |
| 369 | }); |
| 370 | let _ = ws |
| 371 | .send(Message::Text( |
| 372 | serde_json::json!({"type": "error", "reason": why.reason()}) |
| 373 | .to_string() |
| 374 | .into(), |
| 375 | )) |
| 376 | .await; |
| 377 | let _ = ws.close(None).await; |
| 378 | return Ok(()); |
| 379 | } |
| 380 | |
| 381 | state.record_success().await; |
| 382 | state.emit(Event::Accepted { |
| 383 | origin: origin.clone().unwrap_or_default(), |
| 384 | }); |
| 385 | |
| 386 | let is_tmux = state.config.profile.program == "tmux"; |
| 387 | ws.send(Message::Text( |
| 388 | serde_json::json!({ |
| 389 | "type": "ok", |
| 390 | "profile": state.config.profile.program, |
| 391 | "tmux": is_tmux, |
| 392 | "defaultSession": state.config.adopted_session() |
| 393 | .unwrap_or_else(|| state.config.default_session.clone()), |
| 394 | // Enumerated server-side; the client picks from reality rather |
| 395 | // than inventing names. |
| 396 | "sessions": if is_tmux { crate::pty::list_sessions(&state.config.profile.tmux_global_args()) } else { Vec::new() }, |
| 397 | }) |
| 398 | .to_string() |
| 399 | .into(), |
| 400 | )) |
| 401 | .await?; |
| 402 | |
| 403 | if state.config.echo_only { |
| 404 | return echo_loop(ws).await; |
| 405 | } |
| 406 | pty_loop(ws, &state.config).await |
| 407 | } |
| 408 | |
| 409 | /// Post-auth session: binary frames are raw terminal bytes in both directions, |
| 410 | /// text frames are JSON control messages. |
| 411 | async fn pty_loop<S>( |
| 412 | mut ws: WebSocketStream<S>, |
| 413 | config: &Config, |
| 414 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 415 | where |
| 416 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 417 | { |
| 418 | // Wait for the client to tell us its size before spawning, so the shell's |
| 419 | // first prompt is drawn at the right width. |
| 420 | let (cols, rows, requested) = match tokio::time::timeout(Duration::from_secs(5), ws.next()).await |
| 421 | { |
| 422 | Ok(Some(Ok(Message::Text(t)))) => parse_open(&t).unwrap_or((80, 24, None)), |
| 423 | _ => (80, 24, None), |
| 424 | }; |
| 425 | |
| 426 | // The client may name a tmux session, but only a name that survives |
| 427 | // validation, and only when we are actually running tmux. Anything else |
| 428 | // silently falls back to the configured default rather than erroring — |
| 429 | // there is no path here from client input to an arbitrary program. |
| 430 | let profile = if config.profile.program == "tmux" { |
| 431 | match requested { |
| 432 | Some(name) => config.profile.with_session(&name), |
| 433 | None => match config.adopted_session() { |
| 434 | Some(only) => config.profile.with_session(&only), |
| 435 | None => config.profile.clone(), |
| 436 | }, |
| 437 | } |
| 438 | } else { |
| 439 | config.profile.clone() |
| 440 | }; |
| 441 | let profile = &profile; |
| 442 | |
| 443 | let spawned = match crate::pty::PtySession::spawn(profile, cols, rows) { |
| 444 | Ok(s) => s, |
| 445 | Err(e) => { |
| 446 | let _ = ws |
| 447 | .send(Message::Text( |
| 448 | serde_json::json!({"type": "error", "reason": e.to_string()}) |
| 449 | .to_string() |
| 450 | .into(), |
| 451 | )) |
| 452 | .await; |
| 453 | return Ok(()); |
| 454 | } |
| 455 | }; |
| 456 | |
| 457 | let crate::pty::Spawned { |
| 458 | session, |
| 459 | mut output, |
| 460 | mut exit, |
| 461 | } = spawned; |
| 462 | |
| 463 | // tmux is the source of truth for which session we're on, what else exists, |
| 464 | // and what Claude Code is doing in it — and all of that changes without |
| 465 | // anything crossing this socket. A control-mode client in its own task both |
| 466 | // watches for those changes and carries the sidebar's requests back. |
| 467 | let (status_tx, mut status_rx) = mpsc::channel::<String>(8); |
| 468 | let (tmux_tx, tmux_rx) = mpsc::channel::<TmuxRequest>(8); |
| 469 | if profile.program == "tmux" { |
| 470 | // The session the interactive client was pointed at. Only used to give |
| 471 | // the control client something to attach to; from then on tmux tells us |
| 472 | // where that client actually is. |
| 473 | let started_on = profile |
| 474 | .args |
| 475 | .iter() |
| 476 | .position(|a| a == "-s") |
| 477 | .and_then(|i| profile.args.get(i + 1)) |
| 478 | .cloned() |
| 479 | .unwrap_or_else(|| config.default_session.clone()); |
| 480 | tokio::spawn(control_loop( |
| 481 | started_on, |
| 482 | profile.tmux_global_args(), |
| 483 | session.tty_name(), |
| 484 | status_tx, |
| 485 | tmux_rx, |
| 486 | )); |
| 487 | } |
| 488 | |
| 489 | loop { |
| 490 | tokio::select! { |
| 491 | // pty -> browser |
| 492 | chunk = output.recv() => { |
| 493 | match chunk { |
| 494 | Some(bytes) => ws.send(Message::Binary(bytes.into())).await?, |
| 495 | None => { |
| 496 | // EOF on the pty beats the child being reaped, so wait |
| 497 | // briefly for the status rather than dropping the |
| 498 | // sidebar with no explanation. |
| 499 | let code = tokio::time::timeout(Duration::from_secs(2), &mut exit) |
| 500 | .await |
| 501 | .ok() |
| 502 | .and_then(|r| r.ok()) |
| 503 | .unwrap_or(-1); |
| 504 | let _ = ws.send(Message::Text( |
| 505 | serde_json::json!({"type": "exit", "code": code}).to_string().into() |
| 506 | )).await; |
| 507 | break; |
| 508 | } |
| 509 | } |
| 510 | } |
| 511 | // browser -> pty |
| 512 | msg = ws.next() => { |
| 513 | match msg { |
| 514 | Some(Ok(Message::Binary(b))) => { |
| 515 | if !session.write(b.to_vec()) { break; } |
| 516 | } |
| 517 | // Text is control only. Keystrokes must arrive as binary so |
| 518 | // that non-UTF-8 input is never mangled. |
| 519 | Some(Ok(Message::Text(t))) => { |
| 520 | if let Some((c, r)) = parse_resize(&t) { |
| 521 | let _ = session.resize(c, r); |
| 522 | } |
| 523 | if let Some(req) = parse_tmux_request(&t) { |
| 524 | // Full queue means the control client is wedged; |
| 525 | // dropping the request beats stalling the terminal. |
| 526 | let _ = tmux_tx.try_send(req); |
| 527 | } |
| 528 | } |
| 529 | Some(Ok(Message::Close(_))) | None => break, |
| 530 | Some(Err(_)) => break, |
| 531 | _ => {} |
| 532 | } |
| 533 | } |
| 534 | Some(json) = status_rx.recv() => { |
| 535 | ws.send(Message::Text(json.into())).await?; |
| 536 | } |
| 537 | code = &mut exit => { |
| 538 | let code = code.unwrap_or(-1); |
| 539 | let _ = ws.send(Message::Text( |
| 540 | serde_json::json!({"type": "exit", "code": code}).to_string().into() |
| 541 | )).await; |
| 542 | break; |
| 543 | } |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | // Dropping the session kills the tmux *client*. The tmux server, and the |
| 548 | // session itself, keep running for the next attach. |
| 549 | drop(session); |
| 550 | let _ = ws.close(None).await; |
| 551 | Ok(()) |
| 552 | } |
| 553 | |
| 554 | /// The complete set of things the sidebar may ask tmux to do. |
| 555 | /// |
| 556 | /// An enum rather than a command string, so the wire protocol cannot express |
| 557 | /// anything outside this list. Each variant's payload is validated at parse |
| 558 | /// time, and the command lines built from them are the only ones in the daemon |
| 559 | /// that contain client-supplied text. |
| 560 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 561 | pub enum TmuxRequest { |
| 562 | /// Move the live client to an existing session — no reconnect, no new pty. |
| 563 | Switch(String), |
| 564 | /// Create a detached session, then switch to it. |
| 565 | Create(String), |
| 566 | /// Bring a pane into view: select it, its window, and its session. |
| 567 | Focus(String), |
| 568 | /// Make a window of the attached session the current one — the sidebar's |
| 569 | /// tab click. Windows belong to the session, not to a client, so this |
| 570 | /// deliberately moves every client watching that session, exactly as |
| 571 | /// pressing `prefix 2` in the terminal would. |
| 572 | SelectWindow(String), |
| 573 | /// Open a window in a session and select it — the sidebar's "+". |
| 574 | NewWindow(String), |
| 575 | /// Close a window and everything running in it — the sidebar's tab ✕. |
| 576 | /// |
| 577 | /// The one entry here that destroys anything, and tmux has no undo for it. |
| 578 | /// The window id keeps the blast radius to exactly one window; the sidebar |
| 579 | /// additionally refuses to offer it for a session's last window, which |
| 580 | /// would take the session and our own client with it. |
| 581 | KillWindow(String), |
| 582 | } |
| 583 | |
| 584 | fn parse_tmux_request(text: &str) -> Option<TmuxRequest> { |
| 585 | let v: serde_json::Value = serde_json::from_str(text).ok()?; |
| 586 | if v.get("type")?.as_str()? != "tmux" { |
| 587 | return None; |
| 588 | } |
| 589 | let arg = |k: &str| v.get(k).and_then(|x| x.as_str()); |
| 590 | match v.get("cmd")?.as_str()? { |
| 591 | "switch" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::Switch), |
| 592 | "create" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::Create), |
| 593 | "focus" => crate::pty::valid_pane_id(arg("pane")?).map(TmuxRequest::Focus), |
| 594 | "select-window" => { |
| 595 | crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::SelectWindow) |
| 596 | } |
| 597 | "new-window" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::NewWindow), |
| 598 | "kill-window" => crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::KillWindow), |
| 599 | _ => None, |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | /// Command lines for a request. `tty` identifies our interactive client, so the |
| 604 | /// switch moves *it* rather than whichever client tmux would otherwise pick. |
| 605 | fn command_lines(req: &TmuxRequest, tty: Option<&str>) -> Vec<String> { |
| 606 | let client = tty |
| 607 | .and_then(crate::pty::valid_tty) |
| 608 | .map(|t| format!(" -c '{t}'")) |
| 609 | .unwrap_or_default(); |
| 610 | match req { |
| 611 | TmuxRequest::Switch(name) => vec![format!("switch-client{client} -t '{name}'")], |
| 612 | TmuxRequest::Create(name) => vec![ |
| 613 | // -A so a name that already exists attaches instead of failing, |
| 614 | // matching what the sidebar's session field has always done. |
| 615 | format!("new-session -d -A -s '{name}'"), |
| 616 | format!("switch-client{client} -t '{name}'"), |
| 617 | ], |
| 618 | TmuxRequest::Focus(pane) => vec![format!( |
| 619 | "select-pane -t '{pane}' ; select-window -t '{pane}' ; switch-client{client} -t '{pane}'" |
| 620 | )], |
| 621 | // No `-c`: a window id already identifies its session, and selecting a |
| 622 | // window is a property of the session rather than of our client. |
| 623 | TmuxRequest::SelectWindow(id) => vec![format!("select-window -t '{id}'")], |
| 624 | // `-a` inserts after the current window instead of claiming an index |
| 625 | // that may already be taken, which is an error rather than a shuffle. |
| 626 | // The trailing colon targets the session's current window. |
| 627 | TmuxRequest::NewWindow(name) => vec![format!("new-window -a -t '{name}:'")], |
| 628 | TmuxRequest::KillWindow(id) => vec![format!("kill-window -t '{id}'")], |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | /// Owns the control-mode client: pushes a status frame whenever tmux says |
| 633 | /// something changed, and runs the sidebar's requests. |
| 634 | async fn control_loop( |
| 635 | session: String, |
| 636 | global_args: Vec<String>, |
| 637 | tty: Option<String>, |
| 638 | status_tx: mpsc::Sender<String>, |
| 639 | mut requests: mpsc::Receiver<TmuxRequest>, |
| 640 | ) { |
| 641 | let Some((control, mut notifications)) = attach_with_retry(&session, &global_args).await else { |
| 642 | return; |
| 643 | }; |
| 644 | // Claude Code's hook records live on disk and change without tmux noticing, |
| 645 | // so a slow tick backs up the push notifications. It reads a handful of |
| 646 | // small files; there is no process spawn on this path at all. |
| 647 | let mut ticker = tokio::time::interval(Duration::from_secs(1)); |
| 648 | let mut last: Option<crate::status::Snapshot> = None; |
| 649 | |
| 650 | loop { |
| 651 | let snap = crate::status::snapshot(&control, tty.as_deref()).await; |
| 652 | if last.as_ref() != Some(&snap) { |
| 653 | let json = serde_json::json!({ |
| 654 | "type": "status", |
| 655 | "session": snap.session, |
| 656 | "sessions": snap.sessions, |
| 657 | "windows": snap.windows, |
| 658 | "agents": snap.agents, |
| 659 | }) |
| 660 | .to_string(); |
| 661 | if status_tx.send(json).await.is_err() { |
| 662 | return; // Connection gone. |
| 663 | } |
| 664 | last = Some(snap); |
| 665 | } |
| 666 | |
| 667 | tokio::select! { |
| 668 | _ = ticker.tick() => {} |
| 669 | note = notifications.recv() => { |
| 670 | match note { |
| 671 | // Uninteresting notifications are the common case; skip the |
| 672 | // round trip rather than rebuilding the snapshot for a |
| 673 | // layout change nobody displays. |
| 674 | Some(n) if !crate::control::is_interesting(&n) => continue, |
| 675 | Some(_) => {} |
| 676 | None => return, |
| 677 | } |
| 678 | } |
| 679 | req = requests.recv() => { |
| 680 | let Some(req) = req else { return }; |
| 681 | for line in command_lines(&req, tty.as_deref()) { |
| 682 | if let Err(reason) = control.run(line).await { |
| 683 | let json = serde_json::json!({ |
| 684 | "type": "tmux-error", "reason": reason, |
| 685 | }).to_string(); |
| 686 | let _ = status_tx.send(json).await; |
| 687 | break; |
| 688 | } |
| 689 | } |
| 690 | } |
| 691 | } |
| 692 | } |
| 693 | } |
| 694 | |
| 695 | /// The interactive client creates the session, and we may get here first. |
| 696 | async fn attach_with_retry( |
| 697 | session: &str, |
| 698 | global_args: &[String], |
| 699 | ) -> Option<( |
| 700 | crate::control::Control, |
| 701 | mpsc::Receiver<crate::control::Notification>, |
| 702 | )> { |
| 703 | for _ in 0..10 { |
| 704 | if let Ok(pair) = crate::control::Control::attach(session, global_args).await { |
| 705 | // A failed attach still spawns: confirm the client is actually |
| 706 | // talking before handing it out. |
| 707 | if pair.0.run("display-message -p ok").await.is_ok() { |
| 708 | return Some(pair); |
| 709 | } |
| 710 | } |
| 711 | tokio::time::sleep(Duration::from_millis(200)).await; |
| 712 | } |
| 713 | None |
| 714 | } |
| 715 | |
| 716 | fn parse_open(text: &str) -> Option<(u16, u16, Option<String>)> { |
| 717 | let v: serde_json::Value = serde_json::from_str(text).ok()?; |
| 718 | if v.get("type")?.as_str()? != "open" { |
| 719 | return None; |
| 720 | } |
| 721 | let (cols, rows) = dims(&v); |
| 722 | let session = v |
| 723 | .get("session") |
| 724 | .and_then(|x| x.as_str()) |
| 725 | .and_then(crate::pty::valid_session_name); |
| 726 | Some((cols, rows, session)) |
| 727 | } |
| 728 | |
| 729 | fn parse_resize(text: &str) -> Option<(u16, u16)> { |
| 730 | let v: serde_json::Value = serde_json::from_str(text).ok()?; |
| 731 | match v.get("type")?.as_str()? { |
| 732 | "resize" | "open" => Some(dims(&v)), |
| 733 | _ => None, |
| 734 | } |
| 735 | } |
| 736 | |
| 737 | fn dims(v: &serde_json::Value) -> (u16, u16) { |
| 738 | let get = |k: &str, d: u64| v.get(k).and_then(|x| x.as_u64()).unwrap_or(d).clamp(1, 1000) as u16; |
| 739 | (get("cols", 80), get("rows", 24)) |
| 740 | } |
| 741 | |
| 742 | async fn echo_loop<S>( |
| 743 | mut ws: WebSocketStream<S>, |
| 744 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 745 | where |
| 746 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 747 | { |
| 748 | while let Some(Ok(msg)) = ws.next().await { |
| 749 | match msg { |
| 750 | Message::Text(t) => ws.send(Message::Text(t)).await?, |
| 751 | Message::Binary(b) => ws.send(Message::Binary(b)).await?, |
| 752 | Message::Close(_) => break, |
| 753 | _ => {} |
| 754 | } |
| 755 | } |
| 756 | Ok(()) |
| 757 | } |
| 758 | |
| 759 | #[cfg(test)] |
| 760 | mod tests { |
| 761 | use super::*; |
| 762 | |
| 763 | /// A lone session is what the user is already working in, so a client that |
| 764 | /// names none should land there rather than in a fresh `browser` session. |
| 765 | /// Two sessions is ambiguous, and the configured default wins again. |
| 766 | /// |
| 767 | /// Runs against a real tmux on a private socket; skipped where there is no |
| 768 | /// tmux to talk to. |
| 769 | #[test] |
| 770 | fn a_single_existing_session_is_adopted() { |
| 771 | if !crate::pty::Profile::tmux_available() { |
| 772 | eprintln!("skipping: no tmux on PATH"); |
| 773 | return; |
| 774 | } |
| 775 | let socket = "termbridge-sole-session-test"; |
| 776 | let tmux = |args: &[&str]| { |
| 777 | std::process::Command::new("tmux") |
| 778 | .args(["-L", socket]) |
| 779 | .args(args) |
| 780 | .stdout(std::process::Stdio::null()) |
| 781 | .stderr(std::process::Stdio::null()) |
| 782 | .status() |
| 783 | }; |
| 784 | let _ = tmux(&["kill-server"]); |
| 785 | |
| 786 | let mut config = Config::new("t", vec![]); |
| 787 | config.profile = crate::pty::Profile { |
| 788 | program: "tmux".into(), |
| 789 | args: vec![ |
| 790 | "-L".into(), |
| 791 | socket.into(), |
| 792 | "new-session".into(), |
| 793 | "-A".into(), |
| 794 | "-s".into(), |
| 795 | "browser".into(), |
| 796 | ], |
| 797 | }; |
| 798 | config.default_session = "browser".into(); |
| 799 | config.adopt_sole_session = true; |
| 800 | |
| 801 | // No server running: nothing to adopt. |
| 802 | assert_eq!(config.adopted_session(), None); |
| 803 | |
| 804 | let _ = tmux(&["new-session", "-d", "-s", "work"]); |
| 805 | assert_eq!(config.adopted_session().as_deref(), Some("work")); |
| 806 | |
| 807 | // Pinned by the command line: the user's choice is not overridden. |
| 808 | config.adopt_sole_session = false; |
| 809 | assert_eq!(config.adopted_session(), None); |
| 810 | config.adopt_sole_session = true; |
| 811 | |
| 812 | // Two sessions is ambiguous, so the profile's own session stands. |
| 813 | let _ = tmux(&["new-session", "-d", "-s", "other"]); |
| 814 | assert_eq!(config.adopted_session(), None); |
| 815 | |
| 816 | let _ = tmux(&["kill-server"]); |
| 817 | } |
| 818 | |
| 819 | /// The wire protocol must not be able to name a tmux command. Anything the |
| 820 | /// sidebar sends either maps to one of the allowlisted variants or is |
| 821 | /// dropped. |
| 822 | #[test] |
| 823 | fn only_the_allowlisted_requests_parse() { |
| 824 | let req = |s: &str| parse_tmux_request(s); |
| 825 | assert_eq!( |
| 826 | req(r#"{"type":"tmux","cmd":"switch","session":"work"}"#), |
| 827 | Some(TmuxRequest::Switch("work".into())) |
| 828 | ); |
| 829 | assert_eq!( |
| 830 | req(r#"{"type":"tmux","cmd":"focus","pane":"%12"}"#), |
| 831 | Some(TmuxRequest::Focus("%12".into())) |
| 832 | ); |
| 833 | assert_eq!( |
| 834 | req(r#"{"type":"tmux","cmd":"select-window","window":"@3"}"#), |
| 835 | Some(TmuxRequest::SelectWindow("@3".into())) |
| 836 | ); |
| 837 | assert_eq!( |
| 838 | req(r#"{"type":"tmux","cmd":"new-window","session":"work"}"#), |
| 839 | Some(TmuxRequest::NewWindow("work".into())) |
| 840 | ); |
| 841 | assert_eq!( |
| 842 | req(r#"{"type":"tmux","cmd":"kill-window","window":"@3"}"#), |
| 843 | Some(TmuxRequest::KillWindow("@3".into())) |
| 844 | ); |
| 845 | // Killing anything larger than a window is still not expressible. |
| 846 | assert_eq!(req(r#"{"type":"tmux","cmd":"kill-session","session":"work"}"#), None); |
| 847 | assert_eq!(req(r#"{"type":"tmux","cmd":"kill-pane","pane":"%3"}"#), None); |
| 848 | assert_eq!(req(r#"{"type":"tmux","cmd":"kill-server"}"#), None); |
| 849 | assert_eq!(req(r#"{"type":"tmux","cmd":"run","command":"rm -rf /"}"#), None); |
| 850 | assert_eq!(req(r#"{"type":"resize","cols":80,"rows":24}"#), None); |
| 851 | } |
| 852 | |
| 853 | /// Names and pane ids are quoted into a command line, so the validators are |
| 854 | /// the boundary. Quotes, semicolons and spaces must not survive parsing. |
| 855 | #[test] |
| 856 | fn hostile_arguments_are_rejected_not_escaped() { |
| 857 | let req = |s: &str| parse_tmux_request(s); |
| 858 | for hostile in [ |
| 859 | r#"{"type":"tmux","cmd":"switch","session":"a' ; kill-server ; '"}"#, |
| 860 | r#"{"type":"tmux","cmd":"switch","session":"-C"}"#, |
| 861 | r#"{"type":"tmux","cmd":"switch","session":"a b"}"#, |
| 862 | r#"{"type":"tmux","cmd":"focus","pane":"%1 ; kill-server"}"#, |
| 863 | r#"{"type":"tmux","cmd":"focus","pane":"$1"}"#, |
| 864 | r#"{"type":"tmux","cmd":"focus","pane":"%"}"#, |
| 865 | r#"{"type":"tmux","cmd":"select-window","window":"@1 ; kill-server"}"#, |
| 866 | r#"{"type":"tmux","cmd":"select-window","window":"%1"}"#, |
| 867 | r#"{"type":"tmux","cmd":"select-window","window":"@"}"#, |
| 868 | // An index is not an id: `2` would be a bare target, and |
| 869 | // `session:2.0` carries syntax of its own. |
| 870 | r#"{"type":"tmux","cmd":"select-window","window":"2"}"#, |
| 871 | r#"{"type":"tmux","cmd":"new-window","session":"a' ; kill-server ; '"}"#, |
| 872 | r#"{"type":"tmux","cmd":"new-window","session":"work:1"}"#, |
| 873 | // The destructive one gets the same validator, and `-a` is the |
| 874 | // difference between one window and every window. |
| 875 | r#"{"type":"tmux","cmd":"kill-window","window":"@1 ; kill-server"}"#, |
| 876 | r#"{"type":"tmux","cmd":"kill-window","window":"-a"}"#, |
| 877 | r#"{"type":"tmux","cmd":"kill-window","window":""}"#, |
| 878 | ] { |
| 879 | assert_eq!(req(hostile), None, "accepted {hostile}"); |
| 880 | } |
| 881 | } |
| 882 | |
| 883 | #[test] |
| 884 | fn window_commands_stay_inside_their_session() { |
| 885 | // No -c: windows belong to the session, so this moves whoever is |
| 886 | // watching it — the same thing `prefix 2` in the terminal does. |
| 887 | let lines = command_lines(&TmuxRequest::SelectWindow("@3".into()), Some("/dev/pts/7")); |
| 888 | assert_eq!(lines, vec!["select-window -t '@3'"]); |
| 889 | |
| 890 | // -a rather than an index, which could collide with an existing window. |
| 891 | let lines = command_lines(&TmuxRequest::NewWindow("work".into()), None); |
| 892 | assert_eq!(lines, vec!["new-window -a -t 'work:'"]); |
| 893 | |
| 894 | // One window, named by id. No -a, which would kill all *but* it. |
| 895 | let lines = command_lines(&TmuxRequest::KillWindow("@3".into()), Some("/dev/pts/7")); |
| 896 | assert_eq!(lines, vec!["kill-window -t '@3'"]); |
| 897 | } |
| 898 | |
| 899 | #[test] |
| 900 | fn switch_targets_our_own_client() { |
| 901 | let lines = command_lines(&TmuxRequest::Switch("work".into()), Some("/dev/pts/7")); |
| 902 | assert_eq!(lines, vec!["switch-client -c '/dev/pts/7' -t 'work'"]); |
| 903 | // No tty is a degraded but safe case: tmux picks a client itself. |
| 904 | let lines = command_lines(&TmuxRequest::Switch("work".into()), None); |
| 905 | assert_eq!(lines, vec!["switch-client -t 'work'"]); |
| 906 | // A tty that doesn't look like one never reaches the command line. |
| 907 | let lines = command_lines(&TmuxRequest::Switch("work".into()), Some("nope'; x")); |
| 908 | assert_eq!(lines, vec!["switch-client -t 'work'"]); |
| 909 | } |
| 910 | |
| 911 | #[test] |
| 912 | fn create_attaches_rather_than_failing_on_an_existing_name() { |
| 913 | let lines = command_lines(&TmuxRequest::Create("scratch".into()), None); |
| 914 | assert_eq!(lines[0], "new-session -d -A -s 'scratch'"); |
| 915 | assert_eq!(lines[1], "switch-client -t 'scratch'"); |
| 916 | } |
| 917 | } |